7 teardown and leaks
GOAL
give the chart element a disconnectedCallback that undoes every line of its connectedCallback, so a chart the host app removed stops reporting back to it.
CONCEPT
an embedded frame 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 registry, until the element 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 element only.
MIRRORS
a chart element another team ships, sitting on a dashboard whose cards can be closed. It listens for a refresh event on window and redraws on a timer of its own, so closing one card has to hand both of those back, and has to leave the other charts on the dashboard 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 chart element a disconnectedCallback that undoes
// every line of its connectedCallback, so a chart the host app
// removed stops reporting back to it.
// CONCEPT: an embedded frame 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 registry, until the
// element 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 element only.
// MIRRORS: a chart element another team ships, sitting on a dashboard
// whose cards can be closed. It listens for a refresh event on
// window and redraws on a timer of its own, so closing one card
// has to hand both of those back, and has to leave the other
// charts on the dashboard 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 redraw 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 registry starts each exercise empty.
vi.useRealTimers();
LIVE_CHARTS.clear();
WORK_DONE = 0;
});
// The host app's own book-keeping: the charts it believes are on
// screen. A chart joins this set when it connects, and the app walks
// the set whenever it has something to broadcast.
const LIVE_CHARTS = new Set<LiveChart>();
// Given: a tally the fixture keeps outside every chart. onDataRefresh
// bumps it whether or not anybody is listening to the chart, so it
// counts the CALLBACK ITSELF running - silencing one chart 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 the element calls back into the host.
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"live-chart": HTMLAttributes<HTMLElement> & {
report?: (redraws: number) => void;
};
}
}
}
// The element another team shipped: somebody else's class, running
// inside the host app'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 LiveChart extends HTMLElement {
// The host sets this as a property when it renders the tag.
report?: (redraws: number) => void;
redraws = 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 element. addEventListener and removeEventListener only
// agree when they are handed the very same function object.
private onDataRefresh = (): void => {
WORK_DONE += 1;
this.redraws += 1;
this.report?.(this.redraws);
};
connectedCallback(): void {
window.addEventListener("data-refresh", this.onDataRefresh);
this.timer = setInterval(this.onDataRefresh, 100);
LIVE_CHARTS.add(this);
}
disconnectedCallback(): void {
// --- TODO 1 ------------------------------------------------------
// BROKEN: the chart leaves the page and hands nothing back. Take
// the data-refresh listener off window and stop the redraw timer,
// using this.onDataRefresh and this.timer - a fresh arrow function
// is a different object, and removeEventListener would shrug at
// it.
// --- TODO 2 ------------------------------------------------------
// BROKEN: the host app still counts this chart among its live
// ones, so a removed chart keeps taking broadcasts and cannot be
// collected. Take THIS chart out of LIVE_CHARTS - only this one.
// The other charts are still on screen and are not yours to close.
}
}
customElements.define("live-chart", LiveChart);
describe("Module 10 / Exercise 7 — teardown and leaks", () => {
it("TODO 1 — a removed chart stops reporting", () => {
const reports: number[] = [];
const { unmount } = render(
<live-chart report={(n) => reports.push(n)} />,
);
window.dispatchEvent(new Event("data-refresh"));
vi.advanceTimersByTime(300);
expect(
reports.length,
"TODO 1 setup — one event, three timer redraws",
).toBe(4);
unmount();
const whileMounted = WORK_DONE;
window.dispatchEvent(new Event("data-refresh"));
expect(
WORK_DONE,
"TODO 1 — the data-refresh listener outlived the chart",
).toBe(whileMounted);
vi.advanceTimersByTime(300);
expect(
WORK_DONE,
"TODO 1 — the redraw timer outlived the chart",
).toBe(whileMounted);
});
it("TODO 2 — removing one chart leaves the other one running", () => {
const removed: number[] = [];
const kept: number[] = [];
const closing = render(<live-chart report={(n) => removed.push(n)} />);
render(<live-chart report={(n) => kept.push(n)} />);
expect(LIVE_CHARTS.size, "TODO 2 setup — two charts mounted").toBe(2);
closing.unmount();
expect(
LIVE_CHARTS.size,
"TODO 2 — the host app still counts the removed chart as live",
).toBe(1);
window.dispatchEvent(new Event("data-refresh"));
expect(
kept.length,
"TODO 2 — the chart still on screen must keep reporting",
).toBe(1);
});
});