zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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-harness before 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:

Ngated commitsungated commitsungated / N
2021412.050
50511012.020
2002014012.005

The gated (correct) case lands at exactly N + 1. The ungated (anti-pattern) case lands at 2N + 1not 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./src/index.ts, require./dist/index.js), 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/index.ts 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:

  • peerDependencies is what makes every consumer resolve the harness against the consumer's own instance. A second copy of @testing-library/react bound to a different React renderer against the consumer's tree produces invalid-hook-call-class failures, and a second RTL copy also keeps its own cleanup() registry — so the consumer's afterEach(cleanup) would silently never see the harness's mounted tree.

  • devDependencies is what makes the package's own vitest run and tsc work standalone.

  • react-dom is listed even though src/index.ts need not import it by name: RTL resolves it internally, and the instance the harness's RTL binds to must be the consumer's.

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:

  • maxCommitCount is required, and so is the options argument to expectNoUpdateDepthLoop. 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.

  • expectNoUpdateDepthLoop returns the RenderPressureResult rather than Promise<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:

  1. hasUpdateDepthError is true — the opportunistic secondary signal. If it fires it is unambiguously a failure, but its absence proves nothing.

  2. 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:

  1. Run applyRenderPressure once at the site with the real, already-correct comparator and read the measured commitCount. Call it G — the site's own gated baseline. G is near parentRenders + 1 for an isolated gate, but a real provider mounts other effects and may sit higher; measure, do not assume.

  2. 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).

  3. Set maxCommitCount = G + Math.floor(parentRenders / 2) — halfway between the measured baseline and the regression floor. At parentRenders = 50 and G = 51: ceiling 76, versus a measured regression at 101.

  4. Write the measured G into 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/index.ts 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 = parentRenders

Non-negotiable properties, each of which the spike's measurement depends on:

  • Every pressure step is its own await act(async () => { rerender(…) }). Never batch multiple rerender() calls inside one act(). 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.

  • Profiler wraps renderFn(i)'s returned element on every step, with the same id in the same tree position, so React reconciles it as one Profiler fiber and onRender accumulates 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 onRender callback 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's afterEach(cleanup): a single it() may drive two gates in sequence (kanban does), and a still-mounted earlier tree keeps committing into a later measurement.

  • renderFn owns 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-app/renderer/view-providers/kanban-board-provider.tsx) 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 /** @vitest-environment jsdom */ docblock: tauri-app/vite.config.ts'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).

SiteGatePressure prop each stepExtra assertion
kanban-board-provider.tsx (#4335)usePropSyncedState ×2 — nowViewState/nowViewStateEqual, calendarViewState/calendarViewStateEqualfresh-but-value-equal object per gate
mindmap-board-provider.tsx (#4333)usePropSyncedStatecollapseState/mindmapCollapseStateEqualfresh-but-value-equal collapsedIds propkeep the separate lastDispatchedCollapseRef dispatch effect out of scope
external-file-editor-provider.tsx (#4334)usePropSyncEffect — whole props/externalFileEditorPropsEqualfresh-but-value-equal propsonSync spy call count is 0
frameset/src/frame-settings-dialog.tsx:221 (#4334)usePropSyncedState<TProps>(initialProps), default Object.isstable initialProps reference — see carve-out belowmount 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.