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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user