From 78172432539b66e3ed4d57dce708b6ce5dae0052 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 26 May 2026 13:41:22 +0300 Subject: [PATCH] fix(admin-bulk-actions): plug stale-closure bug in columns useMemo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header checkbox for 'select all subscriptions' read allVisibleSubscriptionIds, subscriptionSelection, isMultiTariff, toggleAllSubscriptions from a stale closure because those values were declared AFTER the columns useMemo. The warned-about deps were absent, so the header only refreshed by accident when expandedRows or getFilteredSubs changed. Relocate filteredUsers / allVisibleSubscriptionIds / toggleAllSubscriptions above the columns block, list them as deps. Header now reflects current selection without piggybacking on unrelated re-renders. Same commit clears the matching AdminInfoPageEditor warning with an explicit eslint-disable-line comment — the activeLocale omission was already intentional (initial-content lock), just not silenced. --- src/pages/AdminBulkActions.tsx | 135 ++++++++++++++++-------------- src/pages/AdminInfoPageEditor.tsx | 5 +- 2 files changed, 75 insertions(+), 65 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 85a5bd6..578f215 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1778,6 +1778,66 @@ export default function AdminBulkActions() { [tariffFilter, trialOnly], ); + // Selection-related derivations live here so the columns useMemo (below) + // can list them as deps without forward-reference warnings. + // When multiple tariffs are selected, filter users client-side + // (server only supports single tariff_id filter) + const filteredUsers = useMemo(() => { + let result = users; + if (trialOnly) { + result = result.filter( + (u) => u.subscription_is_trial || (u.subscriptions ?? []).some((s) => s.status === 'trial'), + ); + } + return result; + }, [users, trialOnly]); + + const allVisibleSubscriptionIds = useMemo(() => { + const ids: number[] = []; + for (const user of filteredUsers) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + for (const sub of filtered) { + ids.push(sub.id); + } + } + return ids; + }, [filteredUsers, getFilteredSubs]); + + const toggleAllSubscriptions = useCallback(() => { + const allSelected = + allVisibleSubscriptionIds.length > 0 && + allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]); + + if (allSelected) { + const next: Record = {}; + for (const key of Object.keys(subscriptionSelection)) { + const id = Number(key); + if (!allVisibleSubscriptionIds.includes(id)) { + next[id] = subscriptionSelection[id]; + } + } + setSubscriptionSelection(next); + } else { + const next = { ...subscriptionSelection }; + for (const id of allVisibleSubscriptionIds) { + next[id] = true; + } + setSubscriptionSelection(next); + } + + // Auto-expand rows that have filtered subs + const expanded: Record = { ...expandedRows }; + for (const user of users) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + if (filtered.length > 1) { + expanded[user.id] = true; + } + } + setExpandedRows(expanded); + }, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]); + const handleOpenAction = (type: BulkActionType) => { setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; @@ -2125,72 +2185,19 @@ export default function AdminBulkActions() { }, }, ], - [t, formatWithCurrency, expandedRows, toggleExpandRow, getFilteredSubs], + [ + t, + formatWithCurrency, + expandedRows, + toggleExpandRow, + getFilteredSubs, + allVisibleSubscriptionIds, + isMultiTariff, + subscriptionSelection, + toggleAllSubscriptions, + ], ); - // When multiple tariffs are selected, filter users client-side - // (server only supports single tariff_id filter) - const filteredUsers = useMemo(() => { - let result = users; - - // Trial-only filter: show users with trial subscription - if (trialOnly) { - result = result.filter( - (u) => u.subscription_is_trial || (u.subscriptions ?? []).some((s) => s.status === 'trial'), - ); - } - - return result; - }, [users, trialOnly]); - - const allVisibleSubscriptionIds = useMemo(() => { - const ids: number[] = []; - for (const user of filteredUsers) { - const subs = user.subscriptions ?? []; - const filtered = getFilteredSubs(subs); - for (const sub of filtered) { - ids.push(sub.id); - } - } - return ids; - }, [filteredUsers, getFilteredSubs]); - - const toggleAllSubscriptions = useCallback(() => { - const allSelected = - allVisibleSubscriptionIds.length > 0 && - allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]); - - if (allSelected) { - // Deselect all - const next: Record = {}; - for (const key of Object.keys(subscriptionSelection)) { - const id = Number(key); - if (!allVisibleSubscriptionIds.includes(id)) { - next[id] = subscriptionSelection[id]; - } - } - setSubscriptionSelection(next); - } else { - // Select all visible - const next = { ...subscriptionSelection }; - for (const id of allVisibleSubscriptionIds) { - next[id] = true; - } - setSubscriptionSelection(next); - } - - // Auto-expand rows that have filtered subs - const expanded: Record = { ...expandedRows }; - for (const user of users) { - const subs = user.subscriptions ?? []; - const filtered = getFilteredSubs(subs); - if (filtered.length > 1) { - expanded[user.id] = true; - } - } - setExpandedRows(expanded); - }, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]); - const table = useReactTable({ data: filteredUsers, columns, diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index 481f8ea..b875985 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -981,7 +981,10 @@ export default function AdminInfoPageEditor() { const initialContent = pageData.content[activeLocale] ?? pageData.content['ru'] ?? ''; editor.commands.setContent(initialContent); editorPopulated.current = true; - }, [pageData, editor]); // activeLocale intentionally omitted + // activeLocale intentionally omitted — initial content is locked at editor + // mount; subsequent locale changes are routed through switchLocale(). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageData, editor]); // Auto-generate slug from Russian title useEffect(() => {