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:
Generated
+1
@@ -23,6 +23,7 @@
|
|||||||
"react-i18next": "^15.7.4"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "7.29.7",
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"react-i18next": "^15.7.4"
|
"react-i18next": "^15.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "7.29.7",
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -76,6 +84,57 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
|||||||
cards: LayoutGrid,
|
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() {
|
export default function Channels() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const panel = useTabPanel();
|
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
|
// 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.
|
// callback) now that Channels reads the feed filters + navigation straight from their providers.
|
||||||
const onFilterByTag = (tagId: number, name: string) => {
|
// useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the
|
||||||
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
// compiler's memo of a plain closure.
|
||||||
setPage("feed");
|
const onFilterByTag = useCallback(
|
||||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
(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 channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||||
const tagsQuery = useTags();
|
const tagsQuery = useTags();
|
||||||
@@ -143,7 +207,9 @@ export default function Channels() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
||||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
const tagActionOpts = useMemo<Parameters<typeof useChannelTagActions>[0]>(
|
||||||
optimisticToggle: (id, tagId, attach) =>
|
() => ({
|
||||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
optimisticToggle: (id, tagId, attach) =>
|
||||||
(old ?? []).map((ch) =>
|
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||||
ch.id === id
|
(old ?? []).map((ch) =>
|
||||||
? {
|
ch.id === id
|
||||||
...ch,
|
? {
|
||||||
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
...ch,
|
||||||
}
|
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
||||||
: ch,
|
}
|
||||||
|
: ch,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||||
// 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
|
||||||
// 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
|
||||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
// the learned pattern (E8·S4).
|
||||||
// the learned pattern (E8·S4).
|
onSuccess: (id) => {
|
||||||
onSuccess: (id) => {
|
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
},
|
||||||
},
|
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||||
// 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
|
||||||
// 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
|
||||||
// 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.
|
||||||
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
|
onError: () => {
|
||||||
onError: () => {
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||||
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
|
// --- 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 —
|
// 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
|
// 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.
|
// 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
|
// 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,
|
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
|
||||||
// overwriting whatever the user had since typed.
|
// 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
|
// `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,
|
// 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
|
// 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.
|
// visit leaves the box exactly as the user left it.
|
||||||
const focusChannelNameRef = useRef(focusChannelName);
|
const applyFocus = useEffectEvent(() => {
|
||||||
focusChannelNameRef.current = focusChannelName;
|
if (!focusChannelName) return;
|
||||||
useEffect(() => {
|
setSearch(focusChannelName);
|
||||||
const name = focusChannelNameRef.current;
|
|
||||||
if (!name) return;
|
|
||||||
setSearch(name);
|
|
||||||
consumeFocus();
|
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
|
// 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.
|
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
||||||
const resetRef = useRef(filtersResetToken);
|
const resetRef = useRef(filtersResetToken);
|
||||||
@@ -315,34 +385,25 @@ export default function Channels() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
}, [filtersResetToken, setSearch]);
|
}, [filtersResetToken, setSearch]);
|
||||||
|
|
||||||
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
const onView = useCallback(
|
||||||
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
|
||||||
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
|
[onViewChannel, t],
|
||||||
// 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),
|
|
||||||
);
|
);
|
||||||
// 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
|
// 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 "…"
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// 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);
|
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({
|
const channelActions = useChannelActions({
|
||||||
onView,
|
onView,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
@@ -361,41 +422,86 @@ export default function Channels() {
|
|||||||
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
|
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,
|
t,
|
||||||
lang: i18n.language,
|
lang,
|
||||||
prioOptions,
|
allChannels,
|
||||||
userTags,
|
userTags,
|
||||||
bumpPriority,
|
bumpPriority,
|
||||||
setPriority,
|
setPriority,
|
||||||
more: (c) => {
|
rowButtonProps,
|
||||||
const mine = rowMenu.open && rowMenu.target?.id === c.id;
|
openRow,
|
||||||
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"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onView,
|
onView,
|
||||||
toggleTag,
|
toggleTag,
|
||||||
createAndAttachTag,
|
createAndAttachTag,
|
||||||
onFilterByTag,
|
onFilterByTag,
|
||||||
suggestionsFor,
|
suggestionsFor,
|
||||||
onAcceptSuggestion: acceptSuggestion,
|
acceptSuggestion,
|
||||||
onDismissSuggestion: dismissSuggestion,
|
dismissSuggestion,
|
||||||
});
|
]);
|
||||||
|
|
||||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||||
// where they act.
|
// where they act.
|
||||||
@@ -748,7 +854,7 @@ export default function Channels() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
fixedLayout
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
rowProps={rowMenu.triggerProps}
|
rowProps={rowTriggerProps}
|
||||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||||
rowClassName={() => "group"}
|
rowClassName={() => "group"}
|
||||||
keyboardNav
|
keyboardNav
|
||||||
@@ -775,7 +881,7 @@ export default function Channels() {
|
|||||||
aboutKey="about"
|
aboutKey="about"
|
||||||
moreKey="actions"
|
moreKey="actions"
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
rowProps={rowMenu.triggerProps}
|
rowProps={rowTriggerProps}
|
||||||
rowClassName={() => "group"}
|
rowClassName={() => "group"}
|
||||||
/>
|
/>
|
||||||
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
|
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
||||||
|
|
||||||
vi.mock("./navigationContext", () => ({
|
vi.mock("./navigationContext", () => ({
|
||||||
useNavigation: () => ({ page: "channels" }),
|
useNavigation: () => ({ page: "channels" }),
|
||||||
@@ -135,18 +135,16 @@ describe("ChannelsProvider — S8 shareable URL", () => {
|
|||||||
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
|
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
|
||||||
// from earlier in the session over whatever the user has typed into the search box since.
|
// from earlier in the session over whatever the user has typed into the search box since.
|
||||||
describe("ChannelsProvider — the focus-a-channel intent", () => {
|
describe("ChannelsProvider — the focus-a-channel intent", () => {
|
||||||
/** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
|
/** Stands in for Channels.tsx's `applyFocus` — the same effect event, the same consume call. */
|
||||||
function Manager() {
|
function Manager() {
|
||||||
const { focusName, focusToken } = useChannels();
|
const { focusName, focusToken } = useChannels();
|
||||||
const { setSearch, consumeFocus } = useChannelsActions();
|
const { setSearch, consumeFocus } = useChannelsActions();
|
||||||
const nameRef = useRef(focusName);
|
const applyFocus = useEffectEvent(() => {
|
||||||
nameRef.current = focusName;
|
if (!focusName) return;
|
||||||
useEffect(() => {
|
setSearch(focusName);
|
||||||
const name = nameRef.current;
|
|
||||||
if (!name) return;
|
|
||||||
setSearch(name);
|
|
||||||
consumeFocus();
|
consumeFocus();
|
||||||
}, [focusToken, setSearch, consumeFocus]);
|
});
|
||||||
|
useEffect(() => applyFocus(), [focusToken]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { transformSync } from "@babel/core";
|
||||||
|
import reactCompiler from "babel-plugin-react-compiler";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// The React Compiler bails out of a component it cannot prove safe WITHOUT a word: the component
|
||||||
|
// still works, it just re-renders everything under it on every render. Its main tripwire, a ref read
|
||||||
|
// or written during render, is not linted here (`react-hooks/refs` is off, see eslint.config.js), so
|
||||||
|
// nothing caught it. The channel manager is where it hurt (the owner, 2026-10-03): Channels and
|
||||||
|
// DataTable both bailed out, every open, close and reposition of a row's right-click menu rebuilt
|
||||||
|
// the rows and columns, and on "All" rows the menu took ~0.9 s to open and ~0.6 s to close.
|
||||||
|
//
|
||||||
|
// This runs the real compiler (the options reactPlugin.ts gives it) over the files that must stay
|
||||||
|
// compiled and fails on any function it skips, with the compiler's own reason.
|
||||||
|
const MUST_COMPILE = ["components/Channels.tsx", "components/ui/DataTable.tsx"];
|
||||||
|
|
||||||
|
function bailouts(path: string): string[] {
|
||||||
|
const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||||
|
const found: string[] = [];
|
||||||
|
transformSync(source, {
|
||||||
|
filename: path,
|
||||||
|
babelrc: false,
|
||||||
|
configFile: false,
|
||||||
|
parserOpts: { plugins: ["jsx", "typescript"] },
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
reactCompiler,
|
||||||
|
{
|
||||||
|
target: "19",
|
||||||
|
logger: {
|
||||||
|
logEvent: (
|
||||||
|
_file: string | null,
|
||||||
|
e: { kind: string; fnLoc?: unknown; detail?: unknown },
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
e.kind !== "CompileError" &&
|
||||||
|
e.kind !== "CompileSkip" &&
|
||||||
|
e.kind !== "PipelineError"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const loc = e.fnLoc as { start?: { line?: number } } | undefined;
|
||||||
|
const d = e.detail as
|
||||||
|
| { options?: { reason?: string; details?: { message?: string }[] } }
|
||||||
|
| { reason?: string; details?: { message?: string }[] }
|
||||||
|
| undefined;
|
||||||
|
const o =
|
||||||
|
d && "options" in d
|
||||||
|
? d.options
|
||||||
|
: (d as { reason?: string; details?: { message?: string }[] });
|
||||||
|
found.push(
|
||||||
|
`line ${loc?.start?.line}: ${o?.details?.[0]?.message ?? o?.reason ?? e.kind}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the React Compiler", () => {
|
||||||
|
it.each(MUST_COMPILE)("compiles every function in %s", (path) => {
|
||||||
|
expect(bailouts(path)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
|
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
|
||||||
import Pager from "./Pager";
|
import Pager from "./Pager";
|
||||||
@@ -201,24 +201,23 @@ export default function DataTable<T>({
|
|||||||
useListNav(bodyRef, { enabled: keyboardNav && wide });
|
useListNav(bodyRef, { enabled: keyboardNav && wide });
|
||||||
useListNav(cardsRef, { 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
|
// Commit a whole new filter map through the owner (controlled) or internal state.
|
||||||
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
const commitFilters = (next: FilterMap) => {
|
||||||
// `filters` as a dep (which would re-run them on every filter change).
|
if (controlledFilters) onFiltersChange!(next);
|
||||||
const filtersRef = useRef(filters);
|
else setInternalFilters(next);
|
||||||
filtersRef.current = filters;
|
};
|
||||||
// …and the same trick for the CALLBACK, one line further: a controlled caller almost always
|
// The externalFilter/reset effects below read the latest filters and callback through effect
|
||||||
// passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
|
// events, so they re-run only on their own trigger, not on every filter change or on the inline
|
||||||
// through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
|
// `onFiltersChange` a controlled caller passes each render. (They used to ride render-written refs,
|
||||||
// the two effects below NAME it in their dep lists instead of switching the check off.
|
// which made the React Compiler skip this whole component — and with it every row's memo.)
|
||||||
const onFiltersChangeRef = useRef(onFiltersChange);
|
const applyExternalFilter = useEffectEvent((key: string, value: string | string[]) => {
|
||||||
onFiltersChangeRef.current = onFiltersChange;
|
commitFilters({ ...filters, [key]: value });
|
||||||
const commitFilters = useCallback(
|
setPage(0);
|
||||||
(next: FilterMap) => {
|
});
|
||||||
if (controlledFilters) onFiltersChangeRef.current!(next);
|
const clearFilters = useEffectEvent(() => {
|
||||||
else setInternalFilters(next);
|
commitFilters({});
|
||||||
},
|
setPage(0);
|
||||||
[controlledFilters],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
|
// 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.
|
// 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;
|
const externalValue = externalFilter?.value;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalKey === undefined || externalValue === undefined) return;
|
if (externalKey === undefined || externalValue === undefined) return;
|
||||||
commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
|
// 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
|
||||||
setPage(0);
|
applyExternalFilter(externalKey, externalValue);
|
||||||
}, [externalKey, externalValue, commitFilters]);
|
}, [externalKey, externalValue]);
|
||||||
|
|
||||||
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
||||||
resetRef.current = resetFiltersToken;
|
resetRef.current = resetFiltersToken;
|
||||||
commitFilters({});
|
clearFilters();
|
||||||
setPage(0);
|
}, [resetFiltersToken]);
|
||||||
}, [resetFiltersToken, commitFilters]);
|
|
||||||
|
|
||||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user