js-dojo

5 store identity

GOAL

keep a skill tree's store one and the same object across redraws, so its open branches, its subscribers, and its memo()'d branch skills all survive a parent redraw.

CONCEPT

a component body runs on EVERY redraw, so 'new Store()' written in that body hands back a distinct store each pass: the open flags are gone, every subscriber resubscribes to an empty store, and each memo()'d branch sees a new store prop and redraws.

HINT

the class itself is fine - only the line that constructs it is wrong. Where must a value live when it should be built once per component instance instead of once per pass?

MIRRORS

the HUD's skill tree, whose open branches live in one store built a single time for the whole tree. Opening a branch never changes that store's identity, which is why the memo()'d branch skills keep identical props and redraw only through their own subscription.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 08-render-detection/05-store-identity.tsx

Source

// DOJO · Module 8 / Exercise 5 — store identity
// GOAL: keep a skill tree's store one and the same object across
//       redraws, so its open branches, its subscribers, and its
//       memo()'d branch skills all survive a parent redraw.
// CONCEPT: a component body runs on EVERY redraw, so 'new Store()'
//       written in that body hands back a distinct store each pass:
//       the open flags are gone, every subscriber resubscribes to an
//       empty store, and each memo()'d branch sees a new store prop
//       and redraws.
// HINT: the class itself is fine - only the line that constructs it is
//       wrong. Where must a value live when it should be built once
//       per component instance instead of once per pass?
// MIRRORS: the HUD's skill tree, whose open branches live in one store
//       built a single time for the whole tree. Opening a branch never
//       changes that store's identity, which is why the memo()'d
//       branch skills keep identical props and redraw only through
//       their own subscription.
// Run: pnpm koan 08-render-detection/05-store-identity.tsx

import { memo, useMemo, useState, useSyncExternalStore } 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 drew 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();
});

type Listener = () => void;

// A small skill-tree store: it remembers which branches the player
// left open, and tells its subscribers whenever that changes.
class TreeStore {
  private open = new Map<string, boolean>();

  private listeners = new Set<Listener>();

  subscribe = (listener: Listener): (() => void) => {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  };

  isOpen(id: string): boolean {
    return this.open.get(id) ?? false;
  }

  toggle(id: string): void {
    this.open.set(id, !this.isOpen(id));
    for (const listener of [...this.listeners]) {
      listener();
    }
  }
}

interface BranchProps {
  id: string;
  prefix: string;
  store: TreeStore;
}

const BranchSkill = memo(function BranchSkill(p: BranchProps) {
  drew(`${p.prefix}-branch-${p.id}`);
  const open = useSyncExternalStore(p.store.subscribe, () =>
    p.store.isOpen(p.id),
  );
  return (
    <div>
      <button onClick={() => p.store.toggle(p.id)}>
        {`${p.prefix} toggle ${p.id}`}
      </button>
      <span data-testid={`${p.prefix}-${p.id}`}>
        {open ? 'open' : 'closed'}
      </span>
    </div>
  );
});

// The bug specimen. It stays broken on purpose - TODO 1 only asks you
// to predict what it costs.
function ResettingTree() {
  drew('ResettingTree');
  const [, setPoints] = useState(0);
  const store = new TreeStore(); // a NEW store on every pass
  return (
    <div>
      <button onClick={() => setPoints((n) => n + 1)}>leaky redraw</button>
      <BranchSkill id="a" prefix="leaky" store={store} />
      <BranchSkill id="b" prefix="leaky" store={store} />
    </div>
  );
}

// -- TODO 1 -----------------------------------------------------------
// The test opens branch a ('leaky toggle a'), then clicks 'leaky
// redraw'. That pass constructs a fresh TreeStore whose Map is empty,
// and both branches receive it as a brand-new store prop. Replace the
// nulls: what does branch a show after the redraw, and how many times
// has the HUD drawn leaky-branch-a in total?
const AFTER_REDRAW: {
  branchA: string | null;
  branchARedraws: number | null;
} = {
  branchA: null,
  branchARedraws: null,
};

function StableTree() {
  drew('StableTree');
  const [, setPoints] = useState(0);
  // -- TODO 2 ---------------------------------------------------------
  // ResettingTree's bug, copied. Build the store ONCE for the lifetime
  // of this component, so the open branches and the subscriptions
  // survive a redraw and the memo()'d branches keep an identical store
  // prop.
  const store = new TreeStore();
  return (
    <div>
      <button onClick={() => setPoints((n) => n + 1)}>stable redraw</button>
      <BranchSkill id="a" prefix="stable" store={store} />
      <BranchSkill id="b" prefix="stable" store={store} />
    </div>
  );
}

it('TODO 1 — a per-pass store forgets and redraws everything', () => {
  render(<ResettingTree />);
  fireEvent.click(screen.getByRole('button', { name: 'leaky toggle a' }));
  // so far so good
  expect(screen.getByTestId('leaky-a').textContent).toBe('open');
  fireEvent.click(screen.getByRole('button', { name: 'leaky redraw' }));
  // TODO 1: what survives the redraw, and what did it cost?
  expect({
    branchA: screen.getByTestId('leaky-a').textContent,
    branchARedraws: redrawsOf('leaky-branch-a'),
  }).toEqual(AFTER_REDRAW);
});

it('TODO 2 — one store per component instance', () => {
  render(<StableTree />);
  fireEvent.click(screen.getByRole('button', { name: 'stable toggle a' }));
  fireEvent.click(screen.getByRole('button', { name: 'stable redraw' }));
  // TODO 2: the open flag survived the parent's redraw
  expect(screen.getByTestId('stable-a').textContent).toBe('open');
  // TODO 2: mount + toggle only; the parent's redraw was skipped
  expect(redrawsOf('stable-branch-a')).toBe(2);
  // TODO 2: mount only
  expect(redrawsOf('stable-branch-b')).toBe(1);
});

Solution