perf(channels): the row menu opens and closes without re-rendering the table

The React Compiler silently skipped Channels and DataTable (a ref written
during render; the refs lint is off), and even once compiled it dropped the
memo of the rows and columns (they reach filterRows/optionsFromColumns past
the page's hooks) and the columns read the open row. Every open, close and
reposition of the right-click menu rebuilt the whole table: on "All" rows
~0.9 s to open and ~0.6 s to close (dev). Now: effect events instead of the
render-written refs, explicit memos for rows, columns and their inputs, a
module-scope keyOf, and the open row reaches its "…" through a small store.
Measured on 397 rows (dev): ~75 ms to open, ~35 ms to close.

A contract test runs the real compiler over both files and fails on any
skipped function. The dropped memos have no automated guard: they need
the full page rendered with its providers; the measurement above is the
evidence.
This commit is contained in:
2026-10-03 05:10:52 +02:00
parent 3e96211db0
commit cdfe7a8986
6 changed files with 295 additions and 125 deletions
+23 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager from "./Pager";
@@ -201,24 +201,23 @@ export default function DataTable<T>({
useListNav(bodyRef, { enabled: keyboardNav && wide });
useListNav(cardsRef, { enabled: keyboardNav && !wide });
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
// latest resolved map so the externalFilter/reset effects can derive from it without taking
// `filters` as a dep (which would re-run them on every filter change).
const filtersRef = useRef(filters);
filtersRef.current = filters;
// …and the same trick for the CALLBACK, one line further: a controlled caller almost always
// passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
// through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
// the two effects below NAME it in their dep lists instead of switching the check off.
const onFiltersChangeRef = useRef(onFiltersChange);
onFiltersChangeRef.current = onFiltersChange;
const commitFilters = useCallback(
(next: FilterMap) => {
if (controlledFilters) onFiltersChangeRef.current!(next);
else setInternalFilters(next);
},
[controlledFilters],
);
// Commit a whole new filter map through the owner (controlled) or internal state.
const commitFilters = (next: FilterMap) => {
if (controlledFilters) onFiltersChange!(next);
else setInternalFilters(next);
};
// The externalFilter/reset effects below read the latest filters and callback through effect
// events, so they re-run only on their own trigger, not on every filter change or on the inline
// `onFiltersChange` a controlled caller passes each render. (They used to ride render-written refs,
// which made the React Compiler skip this whole component — and with it every row's memo.)
const applyExternalFilter = useEffectEvent((key: string, value: string | string[]) => {
commitFilters({ ...filters, [key]: value });
setPage(0);
});
const clearFilters = useEffectEvent(() => {
commitFilters({});
setPage(0);
});
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
@@ -242,9 +241,9 @@ export default function DataTable<T>({
const externalValue = externalFilter?.value;
useEffect(() => {
if (externalKey === undefined || externalValue === undefined) return;
commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
setPage(0);
}, [externalKey, externalValue, commitFilters]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- a controlled filter commits through the PARENT's setter, which may not run during this table's render; the effect fires once per new external value
applyExternalFilter(externalKey, externalValue);
}, [externalKey, externalValue]);
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
// so a navigation that bumped the token still clears even when it remounts this table fresh
@@ -254,9 +253,8 @@ export default function DataTable<T>({
useEffect(() => {
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
resetRef.current = resetFiltersToken;
commitFilters({});
setPage(0);
}, [resetFiltersToken, commitFilters]);
clearFilters();
}, [resetFiltersToken]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);