Skip to content

perf(table): stop re-rendering every row after the table mounts - #860

Merged
Upd4ting merged 1 commit into
mainfrom
perf/table-single-render
Nov 12, 2026
Merged

Upd4ting merged 1 commit into
mainfrom
perf/table-single-render

Conversation

@Upd4ting

Copy link
Copy Markdown
Member

Summary

On table pages, every row rendered 9 times per page load. Table forwarded its slots to TableBody through a v-for, which Vue compiles as dynamic slots (DYNAMIC_SLOTS), and Vue's shouldUpdateComponent always updates a child that receives dynamic slots. So every Table render re-rendered all the rows. After mount, Table re-renders when it measures the head height and the viewport width, and when syncScrollEdges assigns a new edges object with the same values. Separately, the restored column order replaces the state with an identical array.

 Table render (header, fades, measured widths)
-  TableBody (forced by the forwarded dynamic slots)
-    every row
+  TableBodyOutlet: a functional outlet that tracks bodyContext only
+    TableBody: re-renders when its context or the state it reads changes
  • Table.vue: TableBody is rendered by TableBodyOutlet = () => h(TableBody<T>, { context }, slots). Table's slots are declared with defineSlots<Slots>(), which keeps the contract the forwarding outlet produced: any slot name, any props.
  • tableComponentPinning.ts: the rows read the sticky-pin sides as two boolean computeds. A viewport measurement that pins nothing differently no longer reaches the rows.
  • tableComponentScroll.ts: scroll edges are a reactive object updated per key. An unchanged edge triggers nothing, on mount and on every scroll event.
  • useTablePreferences.ts: restore skips a stored value equal to the state. Table's setup write was persisted and then read back on mount as a new, identical array.

Related issue

ANT-2077, found by ANT-2060's post-mount profile.

Evidence

Rows render once per real change. V8 precise-coverage call counts on the production Table chunk, no throttling, 3 loads per page, the same counts every time. The row count is the TableBody row callback divided by 10 rows.

page rows rendered, before after Table renders, before after
grid /table-view/table-view-users 9× 5× 9 8
wide grid /table-view/table-view-drawer (17 columns, scroll fades) 9× 5× 9 8

The 5 renders left are the hydration render, the column order echoed back by TableView's v-model during hydration, a new empty presenceByRow from the presence layer, and the loading true→false refetch after mount. None of them comes from a layout measurement.

Timing. Low-end profile: 4x CPU and Fast 4G (165 ms RTT, 8.1/1.35 Mbps). Production builds of f5b1cf19 and this branch on two isolated dms-control playgrounds, both behind the same local HTTP/2 proxy. 7 interleaved runs per cell, alternating arm order, 84 runs, 0 failed requests, every load 200 with 10 rows, load average 0.7–2.3. The metric is long-task time after data-dms-ready, median [range]:

page base branch Δ
grid cold 546 [531–602] 443 [422–486] −103 ms (−19%)
grid warm 541 [520–600] 475 [434–489] −66 ms (−12%)
wide grid cold 924 [872–1021] 783 [769–856] −141 ms (−15%)
wide grid warm 901 [862–1009] 761 [755–875] −140 ms (−16%)
edit cold (no table) 134 [125–152] 127 [126–137] none
edit warm (no table) 130 [127–136] 135 [128–142] none

The main thread goes idle 94–172 ms sooner on both grids, with no change on the edit page. The playground pages show 10 rows; the cost scales with rows and cells, and the demo's grid shows 25.

Test. tests/table-preferences-restore.test.ts: a stored column order equal to the state leaves the state's array as it is.

before the fix:  × is left as it is when it already holds the stored value   (AssertionError: Object.is equality)
after the fix:   ✓ is left as it is when it already holds the stored value

No visual change. The wide grid at scroll start, middle and end, the narrow grid, and an expanded row were captured on both builds. Header widths, sticky offsets and fade sides are identical. The pixels differ only in the run's own seeded dates and the account e-mail. The screenshots stay outside this PR.

Verification

  • pnpm run checks: 11 passed, 0 failed.
  • pnpm --dir packages/dms/frontend-vue test: 448 files, 3246 passed, 1 skipped. typecheck and lint pass (0 errors, no new warnings).
  • A slot-type probe in TableView: arbitrary slot names with untyped props still type-check, as on main.
  • verify-dms on this head: smoke and the whole module-pages feature, 8 of 8 journeys PASS. doors: none.
  • The backend suites (test:unit, test:integration) were not run: no backend file changed.

Merge danger

Door: two-way. The change is client rendering only, with no stored data or contract change.

Blast radius: tables. If a slot's presence changes after mount, TableBody picks it up on its next own update rather than on Table's. Table already reads slots.expanded once at setup.

Checklist

  • The pull request title follows Conventional Commits.
  • I added or updated tests when behavior changed.
  • I updated documentation when users or contributors are affected. (No user-facing change.)
  • I documented breaking changes and migration steps. (None.)
  • I did not include credentials or unrelated changes.

Table forwarded its slots to TableBody through a v-for, which Vue compiles
as dynamic slots, so every Table render also re-rendered every row. On
mount, Table re-renders for the measured head height and viewport width
and for scroll edges reassigned with the same values; each pass redrew
the rows.

- Render TableBody from a functional outlet that tracks only its context,
  and declare Table's slots with Vue's Slots type, the contract the
  forwarding outlet inferred.
- Keep the sticky-pin flags the rows read as booleans, so a viewport
  measurement that pins nothing differently does not reach the rows.
- Store scroll edges per key, so an unchanged edge triggers nothing, on
  mount and on every scroll event.
- Skip a table preference restore whose stored value equals the state,
  which replaced the column order with an identical array on mount.
@Upd4ting
Upd4ting merged commit 7e7e827 into main Nov 12, 2026
20 checks passed
@Upd4ting
Upd4ting deleted the perf/table-single-render branch November 12, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant