7 teardown and leaks
GOAL
give the map mod a disconnectedCallback that undoes every line of its connectedCallback, so a panel the player closed stops reporting to the game.
CONCEPT
a frame mod is torn down for free - drop the element and the browser reclaims a whole document, its listeners and its timers with it. A custom element has no document of its own. It is an object on the host's heap, still wired to the host's window and still inside the host's live set, until the mod unwires itself.
HINT
disconnectedCallback runs the moment the host takes the element out of the document. Read connectedCallback line by line and give each line back - with the same references it was handed, and for this one panel only.
MIRRORS
the map mod on the HUD. It counts world ticks and repaints on a timer of its own, so closing the map has to hand both of those back, and has to leave the player's other open panels running.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 10-embedded-mods/07-teardown-and-leaks.tsxSource
// DOJO · Module 10 / Exercise 7 — teardown and leaks
// GOAL: give the map mod a disconnectedCallback that undoes every line
// of its connectedCallback, so a panel the player closed stops
// reporting to the game.
// CONCEPT: a frame mod is torn down for free - drop the element and
// the browser reclaims a whole document, its listeners and its
// timers with it. A custom element has no document of its own.
// It is an object on the host's heap, still wired to the host's
// window and still inside the host's live set, until the mod
// unwires itself.
// HINT: disconnectedCallback runs the moment the host takes the
// element out of the document. Read connectedCallback line by
// line and give each line back - with the same references it
// was handed, and for this one panel only.
// MIRRORS: the map mod on the HUD. It counts world ticks and repaints
// on a timer of its own, so closing the map has to hand both of
// those back, and has to leave the player's other open panels
// running.
// Run: pnpm koan 10-embedded-mods/07-teardown-and-leaks.tsx
import type { HTMLAttributes } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
beforeEach(() => {
// The repaint timer is on the clock, so the clock is ours to turn.
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
// Dropping the fake clock drops whatever intervals are still on it,
// and the live set starts each exercise empty.
vi.useRealTimers();
LIVE_MODS.clear();
WORK_DONE = 0;
});
// The game's own book-keeping: the panels it believes are on screen. A
// mod joins this set when it connects, and the game walks the set
// whenever it has news to broadcast.
const LIVE_MODS = new Set<ModMap>();
// Given: a tally the fixture keeps outside every panel. onWorldTick
// bumps it whether or not anybody is listening to the panel, so it
// counts the CALLBACK ITSELF running - silencing a panel cannot hide a
// listener or a timer that was never handed back.
let WORK_DONE = 0;
// Given: JSX only accepts a tag it has been told about. This one takes
// a prop of its own - `report`, a plain function whose name does not
// start with "on", which is how a mod calls back into the game.
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"mod-map": HTMLAttributes<HTMLElement> & {
report?: (beats: number) => void;
};
}
}
}
// The mod, shipped as a custom element: somebody else's class, running
// inside the game's own page. How it wires itself up on the way in is
// given - only what it hands back on the way out is yours.
class ModMap extends HTMLElement {
// The host sets this as a property when it renders the tag.
report?: (beats: number) => void;
beats = 0;
// Kept so teardown has the handle setInterval gave back.
private timer: ReturnType<typeof setInterval> | undefined;
// A class-field arrow: one function with one identity for the whole
// life of the panel. addEventListener and removeEventListener only
// agree when they are handed the very same function object.
private onWorldTick = (): void => {
WORK_DONE += 1;
this.beats += 1;
this.report?.(this.beats);
};
connectedCallback(): void {
window.addEventListener("world-tick", this.onWorldTick);
this.timer = setInterval(this.onWorldTick, 100);
LIVE_MODS.add(this);
}
disconnectedCallback(): void {
// --- TODO 1 ------------------------------------------------------
// BROKEN: the panel leaves the HUD and hands nothing back. Take the
// world-tick listener off window and stop the repaint timer, using
// this.onWorldTick and this.timer - a fresh arrow function is a
// different object, and removeEventListener would shrug at it.
// --- TODO 2 ------------------------------------------------------
// BROKEN: the game still counts this panel among its live mods, so
// a closed panel keeps taking broadcasts and cannot be collected.
// Take THIS panel out of LIVE_MODS - only this one. The player's
// other panels are still open and are not yours to close.
}
}
customElements.define("mod-map", ModMap);
describe("Module 10 / Exercise 7 — teardown and leaks", () => {
it("TODO 1 — a closed panel stops reporting", () => {
const reports: number[] = [];
const { unmount } = render(<mod-map report={(n) => reports.push(n)} />);
window.dispatchEvent(new Event("world-tick"));
vi.advanceTimersByTime(300);
expect(reports.length, "TODO 1 setup — one tick, three repaints").toBe(4);
unmount();
const whileOpen = WORK_DONE;
window.dispatchEvent(new Event("world-tick"));
expect(
WORK_DONE,
"TODO 1 — the world-tick listener outlived the panel",
).toBe(whileOpen);
vi.advanceTimersByTime(300);
expect(
WORK_DONE,
"TODO 1 — the repaint timer outlived the panel",
).toBe(whileOpen);
});
it("TODO 2 — closing one panel leaves the other one running", () => {
const closed: number[] = [];
const open: number[] = [];
const closing = render(<mod-map report={(n) => closed.push(n)} />);
render(<mod-map report={(n) => open.push(n)} />);
expect(LIVE_MODS.size, "TODO 2 setup — two panels open").toBe(2);
closing.unmount();
expect(
LIVE_MODS.size,
"TODO 2 — the game still counts the closed panel as live",
).toBe(1);
window.dispatchEvent(new Event("world-tick"));
expect(
open.length,
"TODO 2 — the panel still on screen must keep reporting",
).toBe(1);
});
});