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
+197 -91
View File
@@ -1,4 +1,12 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -76,6 +84,57 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
cards: LayoutGrid,
};
// The row menu's identity for a channel. Module scope, as useContextMenu asks: a fresh function per
// render rebuilt its `buttonProps` on every render, and with them every column.
const channelKey = (c: ManagedChannel) => c.id;
// Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A
// tiny store rather than a context: a context change walks the whole table to find its readers (~20
// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
// two buttons whose answer changed.
function createOpenRowStore() {
let open: string | null = null;
const listeners = new Set<() => void>();
return {
get: () => open,
set: (next: string | null) => {
if (next === open) return;
open = next;
listeners.forEach((l) => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => void listeners.delete(l);
},
};
}
type OpenRowStore = ReturnType<typeof createOpenRowStore>;
function RowMoreButton({
id,
label,
menuProps,
register,
openRow,
}: {
id: string;
label: string;
menuProps: Record<string, unknown>;
register: (id: string, el: HTMLButtonElement | null) => void;
openRow: OpenRowStore;
}) {
const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id);
return (
<MoreButton
ref={(el) => register(id, el)}
label={label}
expanded={mine}
menuProps={menuProps}
className={mine ? undefined : "reveal"}
/>
);
}
export default function Channels() {
const me = useMe();
const panel = useTabPanel();
@@ -113,11 +172,16 @@ export default function Channels() {
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
const onFilterByTag = (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
};
// useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the
// compiler's memo of a plain closure.
const onFilterByTag = useCallback(
(tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
},
[setFeedFilters, feedFilters, setPage, t],
);
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useTags();
@@ -143,7 +207,9 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.feedSummary() });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
// A memo for the same reason as the tag actions below: the column memo names it.
const allTags = tagsQuery.data;
const userTags = useMemo(() => (allTags ?? []).filter((t) => !t.system), [allTags]);
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
@@ -181,35 +247,41 @@ export default function Channels() {
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
}
: ch,
const tagActionOpts = useMemo<Parameters<typeof useChannelTagActions>[0]>(
() => ({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
}
: ch,
),
),
),
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
// the learned pattern (E8·S4).
onSuccess: (id) => {
qc.invalidateQueries({ queryKey: qk.channel(id) });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
// failure the tag never actually attached — so the server still suggests it and the ghost chips
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
});
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
// the learned pattern (E8·S4).
onSuccess: (id) => {
qc.invalidateQueries({ queryKey: qk.channel(id) });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
// failure the tag never actually attached — so the server still suggests it and the ghost chips
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
},
}),
[qc],
);
// The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
// column memo below names them (see `columns`).
const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
@@ -291,21 +363,19 @@ export default function Channels() {
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
// overwriting whatever the user had since typed.
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
// changed it, overwriting whatever the user had since typed.
//
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
// which does not even change `page`. The provider then reports `focusName` as null, so a later
// visit leaves the box exactly as the user left it.
const focusChannelNameRef = useRef(focusChannelName);
focusChannelNameRef.current = focusChannelName;
useEffect(() => {
const name = focusChannelNameRef.current;
if (!name) return;
setSearch(name);
const applyFocus = useEffectEvent(() => {
if (!focusChannelName) return;
setSearch(focusChannelName);
consumeFocus();
}, [focusChannelToken, setSearch, consumeFocus]);
});
useEffect(() => applyFocus(), [focusChannelToken]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
@@ -315,34 +385,25 @@ export default function Channels() {
setSearch("");
}, [filtersResetToken, setSearch]);
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
const q = search.trim().toLowerCase();
const visibleChannels = channels.filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
const onView = useCallback(
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
[onViewChannel, t],
);
// Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
// (Inline, not memoised: `visibleChannels` is a fresh array each render, so a useMemo keyed on it
// could never hit its cache — and applySyncFilter is a cheap single pass anyway.)
const syncedChannels = applySyncFilter(visibleChannels, filters.sync);
const s = statusQuery.data;
const onView = (c: ManagedChannel) =>
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
// under the open menu, and its "…" must still know the menu is its own.
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: (c) => c.id });
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: channelKey });
const rowPin = useComfortPin(rowMenu.open);
// The columns read only the menu's STABLE handles. Which row's menu is open reaches each "…" through
// the open-row store instead: read in the columns, it rebuilt every column on each open, close and
// reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows.
const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
const [openRow] = useState(createOpenRowStore);
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
const channelActions = useChannelActions({
onView,
onOpenWizard,
@@ -361,41 +422,86 @@ export default function Channels() {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
})
: [];
// Distinct priority levels present in the catalog → the priority column's filter options.
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
.sort((a, b) => b - a)
.map((p) => ({ value: String(p), label: String(p) }));
const columns = buildChannelColumns({
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
//
// The table's rows and columns are explicit memos, not inline. Both later reach calls the React
// Compiler must assume may mutate them (`filterRows`, `sortRows`, `optionsFromColumns`) past this
// page's hooks, and a compiler memo cannot span a hook call — so inline, the compiler dropped their
// memo without a word, and every render (each open, close and reposition of the row menu) handed
// DataTable new rows and columns: ~0.9 s to open the menu on 400 rows. A useMemo result is frozen.
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
const allChannels = channelsQuery.data;
const q = search.trim().toLowerCase();
const syncFilterValue = filters.sync;
const syncedChannels = useMemo(
() =>
// Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
applySyncFilter(
(allChannels ?? []).filter(
(c) =>
!c.hidden && (!q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)),
),
syncFilterValue,
),
[allChannels, q, syncFilterValue],
);
const s = statusQuery.data;
const columns = useMemo(() => {
const registerMore = (id: string, el: HTMLButtonElement | null) => {
if (el) moreRefs.current.set(id, el);
else moreRefs.current.delete(id);
};
return buildChannelColumns({
t,
lang,
// Distinct priority levels present in the catalog → the priority column's filter options.
prioOptions: [...new Set((allChannels ?? []).map((c) => c.priority))]
.sort((a, b) => b - a)
.map((p) => ({ value: String(p), label: String(p) })),
userTags,
bumpPriority,
setPriority,
more: (c) => (
<RowMoreButton
id={c.id}
label={t("channels.row.menu")}
menuProps={rowButtonProps(c)}
register={registerMore}
openRow={openRow}
/>
),
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
onAcceptSuggestion: acceptSuggestion,
onDismissSuggestion: dismissSuggestion,
});
}, [
t,
lang: i18n.language,
prioOptions,
lang,
allChannels,
userTags,
bumpPriority,
setPriority,
more: (c) => {
const mine = rowMenu.open && rowMenu.target?.id === c.id;
return (
<MoreButton
ref={(el) => {
if (el) moreRefs.current.set(c.id, el);
else moreRefs.current.delete(c.id);
}}
label={t("channels.row.menu")}
expanded={mine}
menuProps={rowMenu.buttonProps(c)}
className={mine ? undefined : "reveal"}
/>
);
},
rowButtonProps,
openRow,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
onAcceptSuggestion: acceptSuggestion,
onDismissSuggestion: dismissSuggestion,
});
acceptSuggestion,
dismissSuggestion,
]);
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
@@ -748,7 +854,7 @@ export default function Channels() {
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowProps={rowTriggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
@@ -775,7 +881,7 @@ export default function Channels() {
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowProps={rowTriggerProps}
rowClassName={() => "group"}
/>
{/* Folded, the status / pager row leaves the band, as the table's does — and the