2 props and attributes
GOAL
the loadout panel a modder shipped draws an empty kit. Hand it the player's loadout through the door that can carry an object, then write down what the other door did to it.
CONCEPT
a JSX prop on a custom element lands as a PROPERTY when the element already has one by that name, and the object arrives whole - same reference, nested arrays and all. A prop with no such property lands as an ATTRIBUTE instead, and an attribute holds one kind of value only: text. React writes it with setAttribute, which runs String() over whatever you passed, so an object becomes the literal text "[object Object]" and the data is gone. A mod behind an iframe has neither door open to it: the host has to reach through contentWindow or post the frame a message.
HINT
the panel is handed the same kit twice, on two different doors, and it opens the wrong one. One line decides that.
MIRRORS
the loadout panel on the pause screen. The shell hands the panel the player's kit and the panel lists the items; hand it the kit as text and the panel lists one proud item called "[object Object]".
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 10-embedded-mods/02-props-and-attributes.tsxSource
// DOJO · Module 10 / Exercise 2 — props and attributes
// GOAL: the loadout panel a modder shipped draws an empty kit. Hand
// it the player's loadout through the door that can carry an
// object, then write down what the other door did to it.
// CONCEPT: a JSX prop on a custom element lands as a PROPERTY when
// the element already has one by that name, and the object
// arrives whole - same reference, nested arrays and all. A prop
// with no such property lands as an ATTRIBUTE instead, and an
// attribute holds one kind of value only: text. React writes it
// with setAttribute, which runs String() over whatever you
// passed, so an object becomes the literal text
// "[object Object]" and the data is gone. A mod behind an
// iframe has neither door open to it: the host has to reach
// through contentWindow or post the frame a message.
// HINT: the panel is handed the same kit twice, on two different
// doors, and it opens the wrong one. One line decides that.
// MIRRORS: the loadout panel on the pause screen. The shell hands the
// panel the player's kit and the panel lists the items; hand it
// the kit as text and the panel lists one proud item called
// "[object Object]".
// Run: pnpm koan 10-embedded-mods/02-props-and-attributes.tsx
import type { HTMLAttributes } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
afterEach(cleanup);
interface Loadout {
owner: string;
slots: string[];
}
const PLAYER_LOADOUT: Loadout = {
owner: "player-one",
slots: ["spark-blade", "ward-charm", "grapple-line"],
};
// The mod: a panel somebody else wrote, registered under its own tag
// name and loaded into the pause screen at runtime. It draws itself
// the moment the shell puts it in the document.
class ModLoadout extends HTMLElement {
// A field declared here is a real property on every instance, and
// that is what lets a JSX prop of the same name land as one.
loadout: Loadout | null = null;
connectedCallback(): void {
const title = this.getAttribute("label") ?? "mod panel";
const source = readSource(this);
this.textContent = isLoadout(source)
? `${title}: ${source.slots.join(", ")}`
: `${title}: ${String(source)}`;
}
}
customElements.define("mod-loadout", ModLoadout);
function isLoadout(value: unknown): value is Loadout {
return (
typeof value === "object" &&
value !== null &&
Array.isArray((value as Loadout).slots)
);
}
// --- TODO 1 ----------------------------------------------------------
// The panel asks the DOM for its kit, and the DOM only ever kept text.
// Look at the two props the shell passes below: data-loadout is
// written with setAttribute, while loadout lands on the instance
// property declared above. Read the door that can carry an object.
function readSource(panel: ModLoadout): unknown {
return panel.getAttribute("data-loadout");
}
// Given: TypeScript has never heard of <mod-loadout>, so the tag and
// the two props the host writes are declared here. Nothing in this
// block is part of the exercise.
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"mod-loadout": HTMLAttributes<HTMLElement> & {
label?: string;
loadout?: Loadout | null;
};
}
}
}
// The host: the game's own pause screen, mounting the mod.
function PauseScreen() {
return (
<mod-loadout
label="squad loadout"
loadout={PLAYER_LOADOUT}
data-loadout={PLAYER_LOADOUT}
/>
);
}
// --- TODO 2 ----------------------------------------------------------
// data-loadout was handed the very same object as loadout. Write down
// the text the DOM is holding for it once the panel has mounted,
// character for character, as an inspector would show it.
const ATTRIBUTE_TEXT: string | null = null;
describe("Module 10 / Exercise 2 — props and attributes", () => {
it("TODO 1 — the panel lists the kit it was handed", () => {
const { container } = render(<PauseScreen />);
const panel = container.querySelector("mod-loadout") as ModLoadout;
// The object prop arrived whole: same reference, nested array and
// all. Nothing was copied and nothing was stringified.
const slots = panel.loadout?.slots;
expect(panel.loadout, "TODO 1 setup").toBe(PLAYER_LOADOUT);
expect(slots, "TODO 1 setup").toBe(PLAYER_LOADOUT.slots);
expect(
panel.textContent,
"TODO 1 — the panel should read its kit off the property",
).toBe("squad loadout: spark-blade, ward-charm, grapple-line");
});
it("TODO 2 — what the attribute door did to that object", () => {
const { container } = render(<PauseScreen />);
const panel = container.querySelector("mod-loadout") as ModLoadout;
expect(
ATTRIBUTE_TEXT,
"TODO 2 — write down the text the DOM kept",
).toBe(panel.getAttribute("data-loadout"));
expect(
ATTRIBUTE_TEXT,
"TODO 2 — not one slot name survived the trip",
).not.toContain("spark-blade");
});
});