4 context punches through
GOAL
watch memo() fail to stop a context update from redrawing a skill node, then quiet the readers by giving the provided value an identity that holds still.
CONCEPT
when a Provider's value fails an Object.is check against the value it carried last time, React redraws every component that reads that context, and memo() is never even asked. A value object written inline inside the provider is a fresh identity on each of the provider's passes, so every reader redraws on every pass.
HINT
two moves, in this order - lift the one datum that really changes out of the context value and render it where it is used, then freeze what is left with useMemo so its identity survives the provider's next pass.
MIRRORS
the HUD's skill tree, where the loadout context hands every node its palette while spent points change on their own clock; rebuild that context value per pass and the memo() on each branch skill stops paying, so the whole tree redraws for one spent point.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 08-render-detection/04-context-punches-through.tsxSource
// DOJO · Module 8 / Exercise 4 — context punches through
// GOAL: watch memo() fail to stop a context update from redrawing a
// skill node, then quiet the readers by giving the provided
// value an identity that holds still.
// CONCEPT: when a Provider's value fails an Object.is check against
// the value it carried last time, React redraws every component
// that reads that context, and memo() is never even asked. A
// value object written inline inside the provider is a fresh
// identity on each of the provider's passes, so every reader
// redraws on every pass.
// HINT: two moves, in this order - lift the one datum that really
// changes out of the context value and render it where it is
// used, then freeze what is left with useMemo so its identity
// survives the provider's next pass.
// MIRRORS: the HUD's skill tree, where the loadout context hands every
// node its palette while spent points change on their own clock;
// rebuild that context value per pass and the memo() on each
// branch skill stops paying, so the whole tree redraws for one
// spent point.
// Run: pnpm koan 08-render-detection/04-context-punches-through.tsx
import {
createContext,
memo,
useContext,
useMemo,
useState,
} from 'react';
import { afterEach, expect, it } from 'vitest';
import {
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
// HUD redraw tally: every component bumps its own name at the top of
// its body, so a test can ask how often the HUD redrew that node.
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 only asks you
// to predict what it costs.
const LeakyLoadout = createContext<{ palette: string; spent: number }>({
palette: 'iron',
spent: 0,
});
const LeakyBranchSkill = memo(function LeakyBranchSkill() {
drew('LeakyBranchSkill');
const loadout = useContext(LeakyLoadout);
return <span>{`leaky palette: ${loadout.palette}`}</span>;
});
const LeakyLeafSkill = memo(function LeakyLeafSkill() {
drew('LeakyLeafSkill');
return <span>leaky leaf</span>;
});
function LeakySkillTree() {
drew('LeakySkillTree');
const [spent, setSpent] = useState(0);
return (
// the value object is built fresh on every tree pass
<LeakyLoadout.Provider value={{ palette: 'iron', spent }}>
<button onClick={() => setSpent(spent + 1)}>leaky spend</button>
<span data-testid="leaky-spent">{spent}</span>
<LeakyBranchSkill />
<LeakyLeafSkill />
</LeakyLoadout.Provider>
);
}
interface Pair {
LeakyBranchSkill: number | null;
LeakyLeafSkill: number | null;
}
// -- TODO 1 -----------------------------------------------------------
// Both children are memo()'d and take no props at all. The branch skill
// reads the loadout context; the leaf skill does not. The test clicks
// 'leaky spend' twice, so LeakySkillTree itself runs 3 times. Replace
// each null with the number of times the HUD redrew that child.
const AFTER_TWO_SPENDS: Pair = {
LeakyBranchSkill: null,
LeakyLeafSkill: null,
};
// -- TODO 2 -----------------------------------------------------------
// The tree below carries LeakySkillTree's bug. Repair it in two steps:
// drop spent from the context type and from the value (FixedSkillTree
// already renders spent itself, and FixedBranchSkill reads only
// .palette), then build what is left with useMemo(..., []) so its
// identity never changes again.
const FixedLoadout = createContext<{ palette: string; spent: number }>({
palette: 'iron',
spent: 0,
});
const FixedBranchSkill = memo(function FixedBranchSkill() {
drew('FixedBranchSkill');
const loadout = useContext(FixedLoadout);
return <span>{`fixed palette: ${loadout.palette}`}</span>;
});
function FixedSkillTree() {
drew('FixedSkillTree');
const [spent, setSpent] = useState(0);
const loadout = { palette: 'ember', spent };
return (
<FixedLoadout.Provider value={loadout}>
<button onClick={() => setSpent(spent + 1)}>fixed spend</button>
<span data-testid="fixed-spent">{spent}</span>
<FixedBranchSkill />
</FixedLoadout.Provider>
);
}
it('TODO 1 — memo cannot block a context update', () => {
render(<LeakySkillTree />);
const spend = screen.getByRole('button', { name: 'leaky spend' });
fireEvent.click(spend);
fireEvent.click(spend);
// mount + 2 clicks, given
expect(redrawsOf('LeakySkillTree')).toBe(3);
// TODO 1: how often did the HUD redraw each child?
expect({
LeakyBranchSkill: redrawsOf('LeakyBranchSkill'),
LeakyLeafSkill: redrawsOf('LeakyLeafSkill'),
}).toEqual(AFTER_TWO_SPENDS);
});
it('TODO 2 — a value that holds still quiets the readers', () => {
render(<FixedSkillTree />);
const spend = screen.getByRole('button', { name: 'fixed spend' });
fireEvent.click(spend);
fireEvent.click(spend);
// TODO 2: spent still counts up where it is rendered
expect(screen.getByTestId('fixed-spent').textContent).toBe('2');
// TODO 2: drawn once at mount, then never again
expect(redrawsOf('FixedBranchSkill')).toBe(1);
});