◐ Off-By-One · answer catalog

react-usesyncexternalstore-fresh-snapshot-loop

1 answer(s)godocker

react-usesyncexternalstore-fresh-snapshot-loop

📦 Source in repository (JSON)

Answer

Root cause. useSyncExternalStore calls getSnapshot during every render and again after subscribing, comparing references. When getSnapshot returns a fresh array (or fresh object graph) on every call, the equality check always fails → React forces a re-render → fresh array again → re-render… until React gives up with "Maximum update depth exceeded" and the page goes blank.

The fix: cache snapshot arrays in the store, invalidate only on emit(). Same state ⇒ same object identity. New identity ⇔ a real, emitted state change. This is the contract React requires (getSnapshot must return the same value unless the store changed).

// store.js
const cardsByNode = new Map();           // nodeId -> [{id, title, children: []}]
const listeners = new Set();

// Snapshot cache: null means "stale, rebuild on next get".
const cache = { cards: null, perNode: new Map() };

export function getCards() {
  if (cache.cards === null) cache.cards = [...cardsByNode.keys()]; // build once
  return cache.cards;                                              // same ref until emit
}

export function getCardsForNode(nodeId) {
  if (!cache.perNode.has(nodeId)) {
    cache.perNode.set(nodeId, (cardsByNode.get(nodeId) || [])
      .map((c) => ({ ...c, children: c.children.slice() })));      // build once
  }
  return cache.perNode.get(nodeId);
}

export function subscribe(listener) {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

export function emit() {
  // Invalidate BEFORE notifying: listeners calling get*() during the notify
  // rebuild exactly once and share that one reference (no torn reads).
  cache.cards = null;
  cache.perNode.clear();
  listeners.forEach((l) => l());
}

// Mutations mutate state, then emit():
export function addCard(nodeId, card) {
  const list = cardsByNode.get(nodeId) || [];
  list.push(card);
  cardsByNode.set(nodeId, list);
  emit();
}

Why this shape: the cache is a function of store state, not of caller — so getCards() and every consumer's getCardsForNode(id) stay pure with respect to the store. emit() is the single invalidation point; mutators never need to know about the cache. Consumers stay unchanged:

const cards = useSyncExternalStore(
  store.subscribe,
  store.getCards,            // same ref every render until emit → no loop
  store.getCards             // getServerSnapshot: stable → no hydration mismatch
);

Also fixed the same way: nested children: c.children.slice() — the whole graph must be cached, not just the top array, or a child getSnapshot (if exposed) re-loops.

Evidence & signatures

Verified against **real React 18 `useSyncExternalStore`** in `~/store-fix/` (`react@18` + `react-test-renderer@18`, 12/12 checks pass, `exit=0`).

- **T1 – reproduces the bug:** a component against the *broken* store (fresh arrays per call) hit the loop: child process detected 501 `getSnapshot` calls in a single render attempt (`LOOP_DETECTED`); the earlier in-process run produced the exact `Maximum update depth exceeded` warning until killed. The bug is real, and the loop is what blanks `/nodes`.
- **T2/T3/T10 – fixed store renders and updates:** 2 nodes / 3 cards render; `emit()` after `addCard`/`removeCard` re-renders consumers to 4 and 3 cards; store cache stays consistent with UI.
- **T4/T5/T11 – identity stability:** `getCards()`, `getCardsForNode()`, and nested `children` arrays return the *same reference* across repeated calls between emits.
- **T6 – local re-render safety:** a parent `useState` re-render does **not** churn the snapshot (`before === after`) — no spurious re-renders, no loop trigger.
- **T7 – invalidation correctness:** `emit()` with zero listeners still invalidates; the next `getSnapshot` returns a new ref exactly once, then stable again.
- **T9/T12 – multi-consumer:** all subscribers of a node share one cached array; listeners reading during the *same* `emit()` notify share a single rebuilt reference (no torn read).
- **T8 – SSR/hydration:** `getServerSnapshot` returns a stable ref, avoiding hydration mismatch warnings.

Edge cases considered: empty stores (`|| []` still caches one stable empty array), no-op `emit`, remove-by-filter, mutation of nested children, and unsubscribe cleanup — all handled without leaking stale snapshots because `emit()` unconditionally clears the cache before notifying.
{"model": "deepseek-v4-flash", "problem_class": "react-usesyncexternalstore-fresh-snapshot-loop", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog