Render Pressure Harness — Locked Design
Status: locked design note. This is the decision record produced by #4331 for the Render Pressure Harness epic. It fixes the package identity, dependency declarations, exported API, assertion semantics, and driving contract of
@takazudo/render-pressure-harnessbefore any of it is built, so the implementation sub-issue and the four application sub-issues all work from the same spec. It contains no harness code. Its empirical basis is the committed spike write-up, Render Pressure Harness — Spike Findings — read that first; every number quoted below comes from it.
Empirical basis (and one correction to carry forward)
The spike measured, at N = 20 / 50 / 200:
| N | gated commits | ungated commits | ungated / N |
|---|---|---|---|
| 20 | 21 | 41 | 2.050 |
| 50 | 51 | 101 | 2.020 |
| 200 | 201 | 401 | 2.005 |
The gated (correct) case lands at exactly N + 1. The ungated (anti-pattern) case lands at 2N + 1 — not the 3N + 1 quoted in the epic body as a planning-time observation. The ratio converges cleanly toward 2 (2.050 → 2.020 → 2.005), which is the asymptote of (2N + 1) / N, so this is the real per-iteration cost of the spike's single-mirror-effect shape and not measurement noise. The plausible source of the 3N + 1 estimate is that several real provider sites pair a state-mirror effect with a separate dispatch effect (lastDispatchedRef-style) that also consumes the mirrored value; two independently-firing unguarded effects per prop change would add a third commit per iteration. The spike's baseline component deliberately isolates the single-mirror case, so it cannot see that third commit.
The multiplier is therefore empirical and shape-dependent. Every decision below follows from that one fact: the harness asserts "a stably higher multiple than this site's own gated baseline", and no multiplier — not 2, not 3 — is hardcoded anywhere in the harness or in any call site.
Decision 1 — Package identity and dependency wiring
New workspace package packages/render-pressure-harness, name @takazudo/render-pressure-harness, "private": true, picked up by the existing packages/* glob in pnpm-workspace.yaml. It mirrors packages/view-provider's shape: main/types/dual exports map (import → ., require → .), build/test/test:watch/typecheck scripts, own tsup.config.ts, own vitest.config.ts with environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"] and resolve.dedupe: ["react", "react-dom"], own vitest-setup.ts calling @testing-library/react's cleanup() in afterEach.
It must not be folded into @takazudo/view-provider: that package is a production runtime dependency of tauri-app, frameset, settings-sections, and app-defaults, and must never pull @testing-library/react toward a production bundle.
Peer + dev, not dev-only
packages/view-provider declares only react as a peer, because nothing in its shipped src/ imports React's DOM or testing surfaces. This package is different: its shipped entry point directly imports and calls Profiler from react and render/act from @testing-library/react. So all three of react, react-dom, and @testing-library/react are declared as both peerDependencies and devDependencies:
peerDependenciesis what makes every consumer resolve the harness against the consumer's own instance. A second copy of@testing-library/reactbound to a different React renderer against the consumer's tree produces invalid-hook-call-class failures, and a second RTL copy also keeps its owncleanup()registry — so the consumer'safterEach(cleanup)would silently never see the harness's mounted tree.devDependenciesis what makes the package's ownvitest runandtscwork standalone.react-domis listed even thoughsrc/need not import it by name: RTL resolves it internally, and the instance the harness's RTL binds to must be the consumer's.index. ts
tsup.config.ts marks all three external (external: ["react", "react-dom",
"@testing-library/react"]) so dist/ never inlines them.
Consumer wiring (all of it done in #4332, so the parallel wave-3b issues never touch the same shared files): @takazudo/render-pressure-harness as a devDependency of both tauri-app and packages/frameset.
Decision 2 — Exported API (locked signatures)
import type { ReactElement } from "react";
export interface RenderPressureResult {
/** Profiler-measured descendant commit count across the whole run (mount + rerenders). */
commitCount: number;
/** Number of rerender steps actually driven (echoes the resolved option). */
parentRenders: number;
/** console.error messages captured during the run (secondary signal). */
consoleErrors: string[];
/** True if any captured message matches /Maximum update depth exceeded/. */
hasUpdateDepthError: boolean;
}
export interface ApplyRenderPressureOptions {
/** Rerender steps AFTER the initial mount. Default 50. */
parentRenders?: number;
/** Profiler id; only matters when reading a failure message. Default "render-pressure". */
profilerId?: string;
}
export interface ExpectNoUpdateDepthLoopOptions extends ApplyRenderPressureOptions {
/** REQUIRED. See Decision 3 — there is no safe universal default. */
maxCommitCount: number;
/** Optional name for this site, included verbatim in the failure message. */
label?: string;
}
export async function applyRenderPressure(
renderFn: (renderIndex: number) => ReactElement,
options?: ApplyRenderPressureOptions,
): Promise<RenderPressureResult>;
export async function expectNoUpdateDepthLoop(
renderFn: (renderIndex: number) => ReactElement,
options: ExpectNoUpdateDepthLoopOptions,
): Promise<RenderPressureResult>;Two deliberate departures from the epic-planning sketch:
maxCommitCountis required, and so is the options argument toexpectNoUpdateDepthLoop. An optional ceiling would let a call site omit it and silently degrade the assertion to the console-error check alone — which the spike proved is a dead signal for exactly this bug shape (Case 3: zero matching console errors at N = 20/50/200 even with the anti-pattern present). Making the ceiling required is what stops the harness from passing vacuously. TypeScript enforces it at every call site.expectNoUpdateDepthLoopreturns theRenderPressureResultrather thanPromise<void>, so a site can log or further assert on the measured number without a second pressure run.
applyRenderPressure asserts nothing — it only measures. It is the tool a call site uses once, by hand, to discover its own gated baseline (Decision 3).
Decision 3 — Assertion semantics
expectNoUpdateDepthLoop throws a descriptive error if either:
hasUpdateDepthErroris true — the opportunistic secondary signal. If it fires it is unambiguously a failure, but its absence proves nothing.commitCount > maxCommitCount— the primary, deterministic signal.
The failure message must include label (when given), profilerId, parentRenders, the measured commitCount, the maxCommitCount it exceeded, and, when non-empty, the captured consoleErrors.
Choosing maxCommitCount (the rule every call site follows)
There is no universal default, and no call site may write a hardcoded multiple of parentRenders. The procedure is:
Run
applyRenderPressureonce at the site with the real, already-correct comparator and read the measuredcommitCount. Call itG— the site's own gated baseline.Gis nearparentRenders + 1for an isolated gate, but a real provider mounts other effects and may sit higher; measure, do not assume.A missing or broken gate adds at least one extra commit per pressure step, so the regression floor is
G + parentRenders(2 commits/step total for a single mirror effect, more for a mirror-plus-dispatch shape).Set
maxCommitCount =— halfway between the measured baseline and the regression floor. AtG + Math. floor(parentRenders / 2) parentRenders = 50andG = 51: ceiling 76, versus a measured regression at 101.Write the measured
Ginto a comment next to the constant so the next reader knows the ceiling is derived, not guessed.
This supersedes the "e.g. parentRenders * 2" suggestion in the wave-3b issue drafts: against the measured 2N + 1 regression shape, a 2N ceiling leaves a margin of exactly one commit, and it is below the baseline-derived ceiling's whole point of scaling with the site rather than with N.
Console-error capture must not depend on Vitest
src/ must not import vitest (that would make the test runner a peer dependency of a package meant to be importable from any of them). Capture by saving console.error, assigning a collecting wrapper that also forwards to the original (so genuine React warnings during the run are never swallowed), and restoring the original in a finally.
Decision 4 — The driving contract
renderFn(0) → initial mount, wrapped in <Profiler id={profilerId} onRender={…}>
renderFn(1..N) → one rerender per step, N = parentRendersNon-negotiable properties, each of which the spike's measurement depends on:
Every pressure step is its own
await act(async () => { rerender(…) }). Never batch multiplererender()calls inside oneact(). Batching collapses the steps into a single flush and dilutes the very pressure the harness claims to apply — a batched loop can report a gated-looking commit count for an ungated component.ProfilerwrapsrenderFn(i)'s returned element on every step, with the sameidin the same tree position, so React reconciles it as one Profiler fiber andonRenderaccumulates the full-run count. A hand-rolled ancestor counter cannot substitute: an ancestor counting its own renders cannot see a child's internally-triggered re-renders at all, which is the entire signal.The
onRendercallback is created once, before the loop, and closes over a mutable counter — a fresh callback identity per step would add avoidable work to the thing being measured.The harness unmounts its own tree in a
finally. It must not rely on the consumer'safterEach(cleanup): a singleit()may drive two gates in sequence (kanban does), and a still-mounted earlier tree keeps committing into a later measurement.renderFnowns prop freshness. The harness never manufactures props. This is deliberate — see Decision 6, where one of the five sites must be driven with a stable reference rather than a fresh one.
parentRenders defaults to 50: the spike's mid measurement point, and — usefully for the secondary signal — the same magnitude as React's NESTED_PASSIVE_UPDATE_LIMIT.
Decision 5 — No per-provider "broken comparator" canaries
calendarViewStateEqual, nowViewStateEqual (both in tauri-) and mindmapCollapseStateEqual (in mindmap-board-provider.tsx) are exported, but their provider components call them through direct same-module lexical bindings. A test-side vi.spyOn / module mock of the exported name does not intercept those internal calls — the well-known same-module ESM limitation — and making the comparator injectable would be production API churn outside this epic's scope.
Therefore: the "harness correctly flags a bad case" proof lives only in the harness package's own tests, where both the gated and ungated components are the harness's own fixtures. Those tests assert the relationship (ungated.commitCount >= gated.commitCount + parentRenders, and a ratio stable across two different N values), never the literal 2N + 1, which is a React- version-sensitive number; record the observed 2N + 1 in a comment instead.
Each per-provider application test proves a strictly narrower claim: today's real, already-correct comparator at this site keeps the commit count bounded under sustained pressure. It does not prove the harness would catch a hypothetical regression at that exact site. Say so in the test file.
Decision 6 — The five application sites
All four new tauri-app test files must carry a / docblock: tauri-'s test block sets setupFiles and exclude but no default environment, so the renderer's component tests opt in per file (see the existing kanban-board-provider.test.tsx).
| Site | Gate | Pressure prop each step | Extra assertion |
|---|---|---|---|
kanban-board-provider.tsx (#4335) | usePropSyncedState ×2 — nowViewState/nowViewStateEqual, calendarViewState/calendarViewStateEqual | fresh-but-value-equal object per gate | — |
mindmap-board-provider.tsx (#4333) | usePropSyncedState — collapseState/mindmapCollapseStateEqual | fresh-but-value-equal collapsedIds prop | keep the separate lastDispatchedCollapseRef dispatch effect out of scope |
external-file-editor-provider.tsx (#4334) | usePropSyncEffect — whole props/externalFileEditorPropsEqual | fresh-but-value-equal props | onSync spy call count is 0 |
frameset/ (#4334) | usePropSyncedState<TProps>(initialProps), default Object.is | stable initialProps reference — see carve-out below | mount with open true |
Note for the usePropSyncEffect site: onSync does not fire on mount either — prevPropRef is initialized to the first render's own prop value, so the mount-time comparison reports "unchanged". The expected total across a full pressure run is therefore 0, not 1.
Carve-out B — frame-settings-dialog.tsx must be driven with a STABLE reference
This is a correction to the wave-3b draft, and the one place where the default "fresh-but-value-equal object every step" driver would assert the opposite of the site's documented contract.
usePropSyncedState<TProps>(initialProps) there takes the default Object.is comparator on an object-typed prop, and the inline comment at lines 214–220 says that is intentional: TProps is opaque and generic, callers hold a stable initialProps reference for as long as the dialog is open, and a new reference is precisely the signal to re-seed the staged edits. Driving it with a fresh object every step would make the gate report "changed" every step by design, produce the ungated commit profile, and fail a bounded assertion — reporting a bug where the code is behaving exactly as specified.
So the pressure driver at this site passes the same initialProps object reference into every step's element. The steps still produce real parent re-renders (each renderFn(i) call builds a fresh element), which is the pressure that matters here: the risk this site actually guards against is a reset loop on parent re-render. Expect a bounded count near parentRenders + 1, with maxCommitCount derived by the Decision 3 procedure as everywhere else.
Unlike the four view-provider sites, this component's prop does not come from registry.renderLeaf's per-render deserialize(), which is why the fresh-reference driver does not apply to it.