js-dojo

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.tsx

Source

// 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);
});

Solution