Skip to content

[R4b] fix(frontend): evaluations list update-depth loop with metric columns - #6204

Merged
mmabrouk merged 1 commit into
release/v0.113.0from
fix/r4b-evaluations-list-update-depth
Aug 22, 2026
Merged

mmabrouk merged 1 commit into
release/v0.113.0from
fix/r4b-evaluations-list-update-depth

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 22, 2026 •

Copy link
Copy Markdown
Member

The bug

Opening /evaluations threw React error #185, "Maximum update depth exceeded", and the error boundary replaced the page. Hit twice by Mahmoud on staging; also reproduced by QA on staging with a real pipeline run.

It only happened once a run carried data.mappings that produce evaluator metric columns — which is why an empty project never reproduced it, and why a first round of seeded data (runs with evaluator references but no mappings) came back clean.

Regression from #6065

The defective write is older than this release: setOutputTypesMap and its subscriber both arrived in abcd1c770c ("metric display fixes"), which is an ancestor of v0.112.3. Production renders these pages fine, so the write alone is not sufficient.

What changed in v0.113.0 is the renderer. #6065 (lane/mobile-extracted-packages) contains both cfb8c27974 ("TanStack Table and Virtual drive the table") and 7628e20346 ("make @agenta/ui runtime antd-free, deleting the antd table"); neither is in v0.112.3. toTanstackColumns builds anonymous header component functions, so a rebuilt column list is a new component type and React remounts the header subtree instead of updating it. Under the old antd table the same rebuild updated in place. That remount re-runs the group header's write, which closes the loop.

Regression from #6065: the table switched to the TanStack renderer, whose per-rebuild anonymous header components remount header subtrees, turning a pre-existing non-idempotent cache write into a self-sustaining loop.

Root cause

setOutputTypesMap notified its listeners unconditionally:

outputTypesCache.set(key, map)
globalVersion += 1
listeners.forEach((listener) => listener())

Its only caller is MetricGroupHeader, which rebuilds the map from the evaluator's schema on render and hands over a new Map with the same content every time. useEvaluationRunsColumns subscribes to that key and its listener calls setOutputTypesVersion(v => v + 1). So:

write -> notify -> setState -> re-render -> rebuild map -> write -> notify -> ...

An infinite synchronous loop through a module-level cache — exactly what React reports: a component sets state from an effect whose dependency changes on every render.

The fix: compare the map's content and return early — no version bump, no notify — when nothing changed.

Isolation matrix

Measured on a live repro, each state loaded fresh, same data:

State Result
neither fix CRASH (0 rows, 0 headers)
the evaluatorWorkflowQueryAtomFamily comparator (#6203) ONLY CRASH (0 rows, 0 headers)
this idempotent write ONLY WORKS (6 rows, metric columns render)
both WORKS

The idempotent write is necessary and sufficient. #6203 stays a separate hardening PR.

Why every stack trace pointed somewhere else

The reported error always surfaced inside Radix composeRefs under ColumnVisibilityTrigger:

dispatchSetState <- setRef <- Array.map <- setRef <- ... <- safelyDetachRef <- commitDeletionEffectsOnFiber

That is where the loop surfaces, not where it starts. docs/design/observability-chrome/evaluations-render-loop.md and SimpleTooltip's own docstring both name that trigger and mark the bug unresolved. Five hypotheses in the table chrome were eliminated by experiment (horizontal overflow, the trigger's tooltip, MetricColumnHeader content, RunMetricCellContent content, the ColumnVisibilityHeader ref callback) before instrumenting identity changes found it: the hook's inputs were all stable (rows 3, everything else 1–2 across 129 renders) while its output churned 69 times, which led through metricNodes → isMetricHidden → outputTypesVersion to this write.

Independent agreement

Two independent reviews reached the same cycle. Codex (gpt-5.6, read-only, clean checkout) named MetricGroupHeader as the cache writer and toTanstackColumns's anonymous header components as the remount mechanism, and proposed essentially this diff. A third static review named the same write, the same listener, and the same conclusion that the Radix anchor ref is only the last update React sees. Codex additionally argued the atom-family comparator is the essential change; the matrix above tests that and shows it is not.

The "existing data" bump — checked, and deliberately not changed here

useEvaluationRunsColumns also does an unconditional bump:

if (hasExistingData) {
    setOutputTypesVersion((v) => v + 1)   // index.tsx:442
}

That is genuinely not idempotent. It is not the driver here, because the effect's dependency (metricGroupsForRendering) resolves to the memoized evaluatorMetricGroups array in this scenario and so is stable in practice (measured: 3 identity changes across 129 renders). Making it idempotent is not a small change in the same spirit — it would need to track what the columns were last built from, which nothing records today. The cheap correct fix is to memoize metricGroupsForRendering (that expression at index.tsx:407 is unmemoized while being an effect dependency). Left out to keep this PR minimal; listed below.

Verification

Dev build of this branch's exact content against a real EE API, six seeded runs whose mappings produce metric columns, mixed statuses with some non-terminal:

Result
before crashed, 0 rows, 0 headers
after 6 rows, and the metric columns render for the first time — "Contains JSON", "Exact Match", "Quality Rating", each with Score and Verdict
after reload with column-visibility state persisted still clean, 6 rows, groups intact

The reload case matters: it is the exact condition the old design doc says was required for the earlier instance of this crash.

What I ran

  • pnpm --filter @agenta/oss exec vitest run — 48 files, 455 passed, 1 skipped
  • pnpm --filter @agenta/oss exec tsc --noEmit --incremental false — clean
  • npx prettier --check on both touched files — clean
  • pnpm build-oss — 10 successful, 10 total, exit 0

Six tests on the rule the loop violated; two are red without the guard (verified by removing it and re-running).

Follow-ups, not in this PR

An ownership smell worth fixing properly: the header mutates the state that produced the header. MetricGroupHeader resolves evaluator output types after mount and writes them into a cache that the column builder reads. The durable fix is to resolve output types before constructing columns and keep headers pure renderers; that is a refactor, not a release fix.

Hygiene items found along the way, to file as one issue after the release:

  1. useSmartResizableColumns returns a fresh headerComponents object literal every render.
  2. metricGroupsForRendering is unmemoized while being an effect dependency (see above).
  3. VirtualTable.tsx:485 spreads onHeaderCell resize props onto a native <th> — the source of the permanent minWidth / onResizeStart / onResizeStop console warnings.
  4. toTanstackColumns builds anonymous header components — a remount hazard by construction, and the mechanism behind this regression.

…columns

Opening /evaluations threw React error #185, "Maximum update depth exceeded",
and the error boundary replaced the page. It only happened once a run carried
`data.mappings` that produce evaluator METRIC columns, which is why an empty
project never reproduced it.

`setOutputTypesMap` notified its listeners unconditionally:

    outputTypesCache.set(key, map)
    globalVersion += 1
    listeners.forEach((listener) => listener())

Its only caller is the metric GROUP header, which rebuilds the map from the
evaluator's schema on render and passes a NEW Map with the SAME content every
time. `useEvaluationRunsColumns` subscribes to that key and its listener calls
`setOutputTypesVersion(v => v + 1)`, so:

    write -> notify -> setState -> re-render -> rebuild map -> write -> notify -> ...

An infinite synchronous loop through a module-level cache, which is exactly what
React reports: a component sets state from an effect whose dependency changes on
every render. The metric columns arm it because the group header is the only
caller.

The fix is to make the write idempotent: compare the map's content and return
early, without bumping the version or notifying, when nothing changed.

Worth recording, because every stack trace pointed elsewhere: the reported error
always surfaced inside Radix's `composeRefs` under `ColumnVisibilityTrigger`
(`dispatchSetState <- setRef <- Array.map <- ... <- safelyDetachRef <-
commitDeletionEffectsOnFiber`). That is where the loop SURFACES, not where it
starts — the churning columns remount the header subtrees and Radix detaches
composed refs during the deletion. Five hypotheses in the table chrome were
eliminated by experiment before instrumenting identity changes showed the hook's
inputs were all stable (rows 3, everything else 1-2 changes across 129 renders)
while its output churned 69 times, which led back through `isMetricHidden` and
`outputTypesVersion` to this write.

Verified on a dev build against a real EE API, with six seeded runs whose
mappings produce metric columns:

    before   crashed, 0 rows, 0 headers
    after    6 rows, and the metric columns render for the first time —
             "Contains JSON", "Exact Match", "Quality Rating", each with
             Score and Verdict
    after a reload with column-visibility state persisted: still clean

Six tests on the rule the loop violated; two are red without the guard.
@vercel

vercel Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 22, 2026 10:16am

Request Review

@CLAassistant

CLAassistant commented Aug 22, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: e448782f-c4d4-4554-85ed-f49955d73fee

📥 Commits

Reviewing files that changed from the base of the PR and between 74d0849 and 39e5d50.

📒 Files selected for processing (2)
  • web/oss/src/components/EvaluationRunsTablePOC/atoms/evaluatorOutputTypes.test.ts
  • web/oss/src/components/EvaluationRunsTablePOC/atoms/evaluatorOutputTypes.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved evaluator output type updates by ignoring unchanged data, preventing unnecessary refreshes and notifications.
    • Ensured additions, removals, value changes, and transitions from unavailable data are detected and reflected correctly.
    • Improved consistency when reading previously stored evaluator output types.
  • Tests

    • Added comprehensive coverage for update detection, stored data behavior, and independent listener notifications.

Walkthrough

The evaluator output type cache now compares incoming maps with cached content. Unchanged maps skip cache replacement, version increments, and listener notifications. New tests cover map changes, null transitions, stored values, and listener isolation.

Changes

Evaluator output type updates

Layer / File(s) Summary
Map equality and update handling
web/oss/src/components/EvaluationRunsTablePOC/atoms/evaluatorOutputTypes.ts
sameOutputTypes compares map identity, size, keys, and values. setOutputTypesMap returns early for equal maps.
Map update behavior tests
web/oss/src/components/EvaluationRunsTablePOC/atoms/evaluatorOutputTypes.test.ts
Tests cover unchanged maps, meaningful changes, null values, stored map contents, unique cache keys, and listener isolation.

Estimated code review effort: 2 (Simple) | ~15 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/r4b-evaluations-list-update-depth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

Copy link
Copy Markdown
Member Author

pnpm build-oss result: green — 10 successful, 10 total, exit 0 (2m31s).

Full local verification for this branch:

  • pnpm --filter @agenta/oss exec vitest run — 48 files, 455 passed, 1 skipped
  • pnpm --filter @agenta/oss exec tsc --noEmit --incremental false — clean
  • npx prettier --check on both touched files — clean
  • pnpm build-oss — green
  • Live: the loop is gone on a dev build against a real EE API with runs whose data.mappings produce metric columns, and the metric columns render for the first time. Holds across a reload with column-visibility state persisted.

@mmabrouk
mmabrouk marked this pull request as ready for review August 22, 2026 10:40
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. bug frontend tests labels Aug 22, 2026
@mmabrouk
mmabrouk merged commit 32a000e into release/v0.113.0 Aug 22, 2026
37 of 39 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-6204.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6204-378035e
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-08-22T10:52:01.607Z

This branch was successfully deployed

1 active deployment
Preview — 39e5d500 Deployed Aug 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug frontend size:M This PR changes 30-99 lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants