js-dojo

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

Source

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

Solution