2 the missed render
GOAL
spot the other failure direction: not a wasted redraw, but a redraw the HUD needed and never got, leaving a skill drawn with the state it had one click ago.
CONCEPT
setState compares the old value with the new one using Object.is, which for objects means "same reference". Mutating a Map keeps its reference, so setState(sameMap) reads as "nothing changed" and React schedules no render at all.
HINT
the HUD only changes during a render. When no render is scheduled, stop reading the Map and ask what the LAST render painted - the data inside the Map is beside the point.
MIRRORS
a skill tree whose store keeps unlocked skills in a Map and mutates it in place, yet never leans on setState noticing: it calls its own listeners, and every branch panel re-reads the store to redraw itself.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 08-render-detection/02-the-missed-render.tsxSource
// DOJO · Module 8 / Exercise 2 — the missed render
// GOAL: spot the other failure direction: not a wasted redraw, but a
// redraw the HUD needed and never got, leaving a skill drawn with
// the state it had one click ago.
// CONCEPT: setState compares the old value with the new one using
// Object.is, which for objects means "same reference". Mutating a
// Map keeps its reference, so setState(sameMap) reads as "nothing
// changed" and React schedules no render at all.
// HINT: the HUD only changes during a render. When no render is
// scheduled, stop reading the Map and ask what the LAST render
// painted - the data inside the Map is beside the point.
// MIRRORS: a skill tree whose store keeps unlocked skills in a Map and
// mutates it in place, yet never leans on setState noticing: it
// calls its own listeners, and every branch panel re-reads the
// store to redraw itself.
// Run: pnpm koan 08-render-detection/02-the-missed-render.tsx
import { useState } from 'react';
import { afterEach, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
// Redraw tally: every panel bumps its own name at the top of its body.
const redraws = new Map<string, number>();
function drew(name: string): void {
redraws.set(name, (redraws.get(name) ?? 0) + 1);
}
function redrawsOf(name: string): number {
return redraws.get(name) ?? 0;
}
afterEach(() => {
cleanup();
redraws.clear();
});
// The bug specimen. It stays broken on purpose - TODO 1 asks what it
// puts on the HUD, not how to repair it.
function StaleSkillTree() {
drew('StaleSkillTree');
const [skills, setSkills] = useState(
() => new Map([['firebolt', 'locked']]),
);
const unlock = (): void => {
skills.set('firebolt', 'unlocked'); // mutates the Map in place
setSkills(skills); // SAME reference - Object.is says "unchanged"
};
return (
<div>
<button onClick={unlock}>unlock</button>
<span data-testid="stale-firebolt">{skills.get('firebolt')}</span>
</div>
);
}
// --- TODO 1 ---------------------------------------------------------
// After one click StaleSkillTree's Map really does say 'unlocked' - but
// data on its own paints nothing. Which word does the HUD show after
// one click of StaleSkillTree's unlock button? Replace null with that
// exact string.
// ---------------------------------------------------------------------
const ON_HUD_AFTER_CLICK: string | null = null;
function FixedSkillTree() {
drew('FixedSkillTree');
const [skills, setSkills] = useState(
() => new Map([['firebolt', 'locked']]),
);
const unlock = (): void => {
// --- TODO 2 -----------------------------------------------------
// StaleSkillTree's bug, copied here. Earn the redraw: build a NEW
// Map that copies the old one, set 'firebolt' on the copy, and pass
// the copy to setSkills - Object.is has to see a different
// reference.
// -----------------------------------------------------------------
skills.set('firebolt', 'unlocked');
setSkills(skills);
};
return (
<div>
<button onClick={unlock}>unlock</button>
<span data-testid="fixed-firebolt">{skills.get('firebolt')}</span>
</div>
);
}
it('TODO 1 - the click the HUD never drew', () => {
render(<StaleSkillTree />);
fireEvent.click(screen.getByRole('button', { name: 'unlock' }));
expect(
screen.getByTestId('stale-firebolt').textContent,
'TODO 1: name the word the HUD actually shows after the click - ' +
'the mutated Map never reached a render',
).toBe(ON_HUD_AFTER_CLICK);
expect(
redrawsOf('StaleSkillTree'),
'TODO 1: the update render never happened - mount only',
).toBe(1);
});
it('TODO 2 - a copied Map earns the redraw', () => {
render(<FixedSkillTree />);
fireEvent.click(screen.getByRole('button', { name: 'unlock' }));
expect(
screen.getByTestId('fixed-firebolt').textContent,
'TODO 2: a fresh Map reference must put the new state on the HUD',
).toBe('unlocked');
expect(
redrawsOf('FixedSkillTree'),
'TODO 2: mount plus the update render - two draws',
).toBe(2);
});