Render Pressure Harness — Spike Findings
Note: This is a spike write-up, not the harness itself. It formalizes the planning-time investigation of React error #185 ("Maximum update depth exceeded") render pressure for the Render Pressure Harness epic, reproducing three repro shapes under
packages/view-provider's existing jsdom + Vitest +@testing-library/reactsetup and recording the actual measured numbers. A later sub-issue builds the reusable harness from the recommendation below; no harness code is added by this spike, and the throwaway spike test file used to produce these numbers was deleted before this issue closed.
See tauri- § "Frontend Pitfalls (React)" for the background: a provider's useEffect mirrors a freshly-deserialize()d prop into local state without a value-equality gate against the previous prop. registry.renderLeaf (packages/) calls provider.deserialize(leaf.props) on every render, returning a fresh object reference every time even when the persisted values are unchanged. The fix pattern is usePropSyncedState / usePropSyncEffect in packages/.
Case 1 — genuinely-changing-value driver (informational, not the target bug shape)
Shape: a component with an unguarded effect (useEffect(() => setLocal(prop)), no dependency array) whose prop's value changes every render, driven by a self-incrementing effect inside the parent — all flushed within one act() call.
Result: confirmed. console.error fires with a message matching /.
One adjustment was necessary to run this safely in a test process: React tracks this specific shape (setState scheduled from inside a passive effect) with its own module-level counter, nestedPassiveUpdateCount, checked against NESTED_PASSIVE_UPDATE_LIMIT (50) — a separate counter/limit pair from nestedUpdateCount / NESTED_UPDATE_LIMIT, which guards the sync/layout-update path (componentWillUpdate/componentDidUpdate-style nested updates) and throws instead of logging. nestedPassiveUpdateCount increments once per commit where that commit's own passive-effect flush scheduled another update on the same root at the same priority, and resets to 0 the moment a commit's passive-effect flush does not schedule a further update — so it only climbs for as long as the self-triggering chain keeps re-triggering itself, uninterrupted. In a real browser, an unbounded self-incrementing effect keeps that chain going indefinitely (a lasting CPU burn) because each render still yields back to the event loop between frames. Under Testing Library's act(), though, passive effects are flushed in a tight synchronous loop with no macrotask yield — so a truly unbounded driver never lets that flush loop terminate at all, and the test process runs out of memory (fresh object allocations every iteration) long before any assertion runs. The repro here caps the self-incrementing driver at 60 iterations (comfortably past the 50-update threshold) and lets it converge afterward. That still trips the console.error signal — nestedPassiveUpdateCount climbs past 50 well before the cap is reached, since the driver's chain is unbroken for 60 consecutive commits — while keeping the test finite and CI-safe.
This is a different bug shape from the one this epic targets. Here the prop's value genuinely changes every render and never converges — the loop is real and would eventually be visible even with a naive "did the value change" check, since the value keeps changing. It is not a template for the harness's driving mechanism; see Case 2 for the shape the harness actually needs to reproduce.
Case 2 — target bug shape, commit-count signal (this IS what the harness must implement)
Shape: an N-step, individually-act()-flushed rerender() loop, each call supplying a fresh object reference with the SAME value (mirroring deserialize() returning a fresh-but-equal object every render). Measured with a Profiler wrapper around two components:
ungated — a raw
useEffect(() => setState(prop), [prop])mirror (the hand-rolled anti-patternusePropSyncedStatereplaces)gated —
usePropSyncedState(prop, isEqual)with a correct value-equality comparator
Result: confirmed, with a corrected multiplier. The gated case tracks commits at N + 1 exactly (mount + N prop-driven rerenders; the equality gate suppresses every internal setState, so there is never a second commit per iteration). The ungated case tracks a stably higher, but different, multiple than the epic's planning-time estimate:
| N | gated commits | ungated commits | ungated / N |
|---|---|---|---|
| 20 | 21 (N+1 = 21) | 41 | 2.050 |
| 50 | 51 (N+1 = 51) | 101 | 2.020 |
| 200 | 201 (N+1 = 201) | 401 | 2.005 |
The measured ungated multiplier is 2N + 1, not the 3N + 1 cited in the epic body as the planning-time observation. The ratio converges cleanly toward 2 as N grows (2.050 → 2.020 → 2.005), which is exactly the asymptotic behavior of (2N + 1) / N, so this is not measurement noise — it's the actual per-iteration cost of this specific ungated shape: one commit for the prop-driven rerender itself, plus exactly one more commit when the single unguarded effect calls setState with a new-but-equal-value object (React's Object.is bail-out on the state setter does not help here, because the new object is a different reference even though its .v is unchanged).
A plausible explanation for the epic's 3N + 1 estimate: tauri- notes that several real provider sites (kanban-board-provider.tsx, inbox-provider.tsx) pair a state-mirror effect with a separate dispatch effect that also consumes the mirrored value (a lastDispatchedRef-style follow-up). A site with two independently-firing unguarded effects per prop change would plausibly add a third commit per iteration (prop render + mirror commit + dispatch commit), producing 3N + 1. This spike's ungated component intentionally isolates the single-mirror-effect case to get a clean baseline number; a harness that also wants to model the two-effect provider shape should expect a higher multiplier and measure it directly rather than assume 3.
Takeaway for the harness: assert "ungated commit count is a stably higher multiple of N than the gated baseline," not a specific constant. The multiplier is empirical and shape-dependent (2 for a single mirror effect measured here, and plausibly 3 for a mirror-plus-dispatch-effect provider); hardcoding "3" would make the harness assertion shape-specific and brittle.
Case 3 — same target-bug-shape case, console-error signal (expected unreliable)
Shape: identical N-step individually-act()-flushed loop from Case 2, ungated component, checking whether console.error fires a message matching /.
Result: confirmed unreliable. console.error does not fire at N = 20, 50, or 200. This is not because separate act() calls reset React's nestedPassiveUpdateCount counter (they don't, categorically — the counter is a module-level value that can persist across commits and events; see Case 1). It's unreliable here because of what this specific shape's chain looks like: each rerender() produces exactly one follow-up passive-effect-triggered commit (the mirror's own setState), and that follow-up commit's own effect flush finds [prop] unchanged since the mirror already applied it — so it does not schedule yet another update, and the counter drops back to 0 before that rerender()'s act() call even returns. The chain never gets a third link, let alone a fiftieth, so nestedPassiveUpdateCount never climbs past 1 no matter how many rerender() iterations run. Case 1's driver is different in kind, not just in degree: its effect chain re-triggers itself on every single commit with no quiet link ever, which is exactly what lets the counter climb toward 50. A provider site with an extra unguarded effect in the mirror chain (e.g. the mirror-plus-dispatch-effect shape from Case 2) could plausibly produce a longer per-iteration chain and behave differently here — this spike only confirms the single-mirror-effect case stays quiet, it does not prove the console.error signal is unreliable for every shape. Either way, this confirms "assert no console.error" cannot be the harness's primary signal for this bug shape — it passes cleanly here even though the ungated anti-pattern is present and actively wasting renders.
Recommendation
Primary signal = bounded Profiler-measured commit count under an N-step, individually-act()-flushed, fresh-but-value-equal-prop rerender loop; console.error-match is a secondary/best-effort check only, not sufficient on its own.
Concretely, the harness should:
Drive the rerender loop with fresh-but-value-equal prop objects (never a converging or genuinely-changing value — that's Case 1's shape, which is a different bug and is already loud via
console.erroron its own).Wrap the tested component in a
Profilerand countonRendercommits across N individually-act()-flushedrerender()calls.Assert the gated/correct implementation stays within a small constant of
N + 1commits.Assert the ungated/anti-pattern implementation produces a stably higher commit count than the gated baseline — as a ratio or multiplier check, not a hardcoded constant, since the multiplier depends on how many independent unguarded effects a given provider site fires per prop change.
Treat a
console.errormatch against/as an optional bonus signal only (useful for Case-1-shaped genuinely-changing-value bugs), never as the harness's pass/fail gate for the fresh-reference / equal-value shape this epic targets.Maximum update depth exceeded/