From f9e508bdaec12a6fec6d52ef1b20e8bdc6fb0da7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Jun 2026 16:06:06 +0800 Subject: [PATCH] perf(channels): optimize card view layout and reduce re-renders - Show 3-column card grid from xl breakpoint instead of 2xl - Cap inline priority/weight width to avoid huge values stretching cards - Collapse right-column grid to content-sized columns, removing wasted space - Memoize channel columns, context value, upstream-update result, and ChannelCard to avoid rebuilding/re-rendering all cards on unrelated state changes --- .../channels/components/channel-card.tsx | 44 +- .../channels/components/channels-columns.tsx | 1158 +++++++++-------- .../channels/components/channels-provider.tsx | 56 +- .../channels/components/channels-table.tsx | 2 +- .../components/numeric-spinner-input.tsx | 3 +- .../hooks/use-channel-upstream-updates.ts | 55 +- 6 files changed, 688 insertions(+), 630 deletions(-) diff --git a/web/default/src/features/channels/components/channel-card.tsx b/web/default/src/features/channels/components/channel-card.tsx index 1663d5dd..166a6df3 100644 --- a/web/default/src/features/channels/components/channel-card.tsx +++ b/web/default/src/features/channels/components/channel-card.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { memo } from 'react' import { flexRender, type Row } from '@tanstack/react-table' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' @@ -36,7 +37,7 @@ const SENSITIVE_MASK = '••••' * priority/weight spinners, balance refresh, response/test times, tag * expand-collapse, and the per-row (or per-tag) actions menu. */ -export function ChannelCard({ row }: { row: Row }) { +function ChannelCardComponent({ row }: { row: Row }) { const { t } = useTranslation() const { sensitiveVisible } = useChannels() const isTagRow = isTagAggregateRow(row.original) @@ -118,25 +119,23 @@ export function ChannelCard({ row }: { row: Row }) { - {/* Right column (sits on the right, content left-aligned) */} -
-
- {t('Priority')} - {t('Weight')} -
{priorityCell}
-
{weightCell}
+ {/* Right column (sits on the right, content left-aligned). A single + grid with content-sized columns keeps Priority/Weight and + Response/Last Tested aligned without wasting horizontal space. */} +
+ {t('Priority')} + {t('Weight')} +
{priorityCell}
+
{weightCell}
+ + {fieldLabels.response_time} + + {fieldLabels.test_time} +
+ {responseCell ?? -}
-
-
- {fieldLabels.response_time} -
-
{fieldLabels.test_time}
-
- {responseCell ?? -} -
-
- {testCell ?? -} -
+
+ {testCell ?? -}
@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row }) {
) } + +/** + * Memoized so each card only re-renders when its own react-table row reference + * changes, instead of every card re-rendering whenever the parent table state + * (filters, pagination, sensitive toggle, etc.) updates. + */ +export const ChannelCard = memo(ChannelCardComponent) diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index f6d6675b..be0d79eb 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -27,7 +27,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ /* eslint-disable react-refresh/only-export-components */ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -384,9 +384,7 @@ function BalanceCell({ channel }: { channel: Channel }) { await handleUpdateChannelBalance(channel.id, queryClient) setIsUpdating(false) } - let remainingBadgeLabel = sensitiveVisible - ? remainingDisplay - : SENSITIVE_MASK + let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK if (sensitiveVisible && isUpdating) { remainingBadgeLabel = t('Updating...') } else if (sensitiveVisible && channel.type === 57) { @@ -488,622 +486,634 @@ export function useChannelsColumns(): ColumnDef[] { const { t, i18n } = useTranslation() const { sensitiveVisible } = useChannels() const locale = i18n.resolvedLanguage || i18n.language - return [ - // Checkbox column - { - id: 'select', - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label='Select all' - /> - ), - cell: ({ row }) => { - const isTagRow = isTagAggregateRow(row.original) - - // Don't show checkbox for tag rows - if (isTagRow) { - return null - } - - return ( + // The column definitions only depend on the translation function, the active + // locale, and sensitive-data visibility. Memoizing keeps the array (and every + // cell renderer reference) stable across unrelated re-renders, so react-table + // does not invalidate the whole row model on each parent render. + return useMemo[]>( + () => [ + // Checkbox column + { + id: 'select', + header: ({ table }) => ( row.toggleSelected(!!value)} - aria-label='Select row' + checked={table.getIsAllPageRowsSelected()} + indeterminate={table.getIsSomePageRowsSelected()} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(!!value) + } + aria-label='Select all' /> - ) + ), + cell: ({ row }) => { + const isTagRow = isTagAggregateRow(row.original) + + // Don't show checkbox for tag rows + if (isTagRow) { + return null + } + + return ( + row.toggleSelected(!!value)} + aria-label='Select row' + /> + ) + }, + enableSorting: false, + enableHiding: false, + size: 40, }, - enableSorting: false, - enableHiding: false, - size: 40, - }, - // ID column - { - accessorKey: 'id', - header: t('ID'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const id = row.getValue('id') as number - return + // ID column + { + accessorKey: 'id', + header: t('ID'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const id = row.getValue('id') as number + return + }, + size: 80, }, - size: 80, - }, - // Name column - { - accessorKey: 'name', - header: t('Name'), - meta: { mobileTitle: true }, - cell: ({ row }) => { - const isTagRow = isTagAggregateRow(row.original) - const name = row.getValue('name') as string - const channel = row.original + // Name column + { + accessorKey: 'name', + header: t('Name'), + meta: { mobileTitle: true }, + cell: ({ row }) => { + const isTagRow = isTagAggregateRow(row.original) + const name = row.getValue('name') as string + const channel = row.original - // Tag row with expand/collapse - if (isTagRow) { - const tag = (row.original as TagRow).tag || name - const childrenCount = (row.original as TagRow).children?.length || 0 + // Tag row with expand/collapse + if (isTagRow) { + const tag = (row.original as TagRow).tag || name + const childrenCount = (row.original as TagRow).children?.length || 0 + + return ( +
+ +
+ Tag:{tag} + +
+
+ ) + } + + // Regular channel row + const settings = parseChannelSettings(channel.setting) + const isPassThrough = settings.pass_through_body_enabled === true + const hasParamOverride = Boolean(channel.param_override?.trim()) return (
- -
- Tag:{tag} -
) - } + }, + minSize: 200, + }, - // Regular channel row - const settings = parseChannelSettings(channel.setting) - const isPassThrough = settings.pass_through_body_enabled === true - const hasParamOverride = Boolean(channel.param_override?.trim()) + // Type column + { + accessorKey: 'type', + header: t('Type'), + cell: ({ row }) => { + const isTagRow = isTagAggregateRow(row.original) - return ( -
-
-
- - {isPassThrough && ( - - - - } - /> - - {t( - 'Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.' - )} - - - - )} - {hasParamOverride && ( - - - - } - /> - - {t('Override request parameters')} - - - - )} - -
- {channel.remark && ( - + if (isTagRow) { + return ( + + ) + } + + const type = row.getValue('type') as number + const typeNameKey = getChannelTypeLabel(type) + const typeName = t(typeNameKey) + const iconName = getChannelTypeIcon(type) + const channel = row.original as Channel + const isMultiKey = isMultiKeyChannel(channel) + const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random' + const MultiKeyModeIcon = + multiKeyMode === 'random' ? Shuffle : ListOrdered + const multiKeyTooltip = + multiKeyMode === 'random' + ? t('Multi-key: Random rotation') + : t('Multi-key: Polling rotation') + + const ionetMeta = parseIonetMeta(channel.other_info) + const isIonet = ionetMeta?.source === 'ionet' + const deploymentId = + typeof ionetMeta?.deployment_id === 'string' + ? ionetMeta?.deployment_id + : undefined + + return ( +
+ {isMultiKey && ( + + } > - {truncateText(channel.remark, 40)} + - - {channel.remark} + + {multiKeyTooltip} + + + + )} + + + + } + > + + + {typeName} + + + {isIonet && ( + + + { + e.stopPropagation() + if (!deploymentId) { + return + } + const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}` + window.open(targetUrl, '_blank', 'noopener') + }} + /> + } + > + + + +
+
+ {t('From IO.NET deployment')} +
+ {deploymentId && ( +
+ {t('Deployment ID')}: {deploymentId} +
+ )} +
+ {t('Click to open deployment')} +
+
)}
-
- ) + ) + }, + filterFn: (row, id, value) => { + if (!value || value.length === 0 || value.includes('all')) { + return true + } + return value.includes(String(row.getValue(id))) + }, + size: 220, + enableSorting: false, }, - minSize: 200, - }, - // Type column - { - accessorKey: 'type', - header: t('Type'), - cell: ({ row }) => { - const isTagRow = isTagAggregateRow(row.original) + // Status column + { + accessorKey: 'status', + header: t('Status'), + meta: { mobileBadge: true }, + cell: ({ row }) => { + const isTagRow = isTagAggregateRow(row.original) + const status = row.getValue('status') as number + const channel = row.original as Channel + + // Tag row: show aggregated status + if (isTagRow) { + const childrenCount = (row.original as TagRow).children?.length || 0 + const hasEnabled = status === 1 + + if (hasEnabled) { + return ( + + ) + } else { + return ( + + ) + } + } + + // Regular channel row + const config = + CHANNEL_STATUS_CONFIG[ + status as keyof typeof CHANNEL_STATUS_CONFIG + ] || CHANNEL_STATUS_CONFIG[0] + + const isMultiKey = isMultiKeyChannel(channel) + const keySize = channel.channel_info?.multi_key_size ?? 0 + const disabledCount = channel.channel_info?.multi_key_status_list + ? Object.keys(channel.channel_info.multi_key_status_list).length + : 0 + const enabledCount = Math.max(0, keySize - disabledCount) + const label = + isMultiKey && keySize > 0 + ? `${t(config.label)} (${enabledCount}/${keySize})` + : t(config.label) + + // Auto-disabled: show reason and time tooltip + if (status === 3) { + let statusReason = '' + let statusTime = '' + try { + const otherInfo = channel.other_info + ? JSON.parse(channel.other_info) + : null + if (otherInfo) { + statusReason = otherInfo.status_reason || '' + statusTime = otherInfo.status_time + ? formatTimestampToDate(otherInfo.status_time) + : '' + } + } catch { + /* empty */ + } + + if (statusReason || statusTime) { + return ( + + + }> + + + +
+ {statusReason && ( +
+ {t('Reason:')} {statusReason} +
+ )} + {statusTime && ( +
+ {t('Time:')} {statusTime} +
+ )} +
+
+
+
+ ) + } + } - if (isTagRow) { return ( + ) + }, + filterFn: (row, id, value) => { + if (!value || value.length === 0 || value.includes('all')) { + return true + } + const status = row.getValue(id) as number + if (value.includes('enabled')) { + return status === 1 + } + if (value.includes('disabled')) { + return status !== 1 + } + return false + }, + size: 120, + enableSorting: false, + }, + + // Models column + { + accessorKey: 'models', + header: t('Models'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const models = row.getValue('models') as string + const modelArray = parseModelsList(models) + return ( + ( + + ))} + /> + ) + }, + size: 200, + enableSorting: false, + }, + + // Group column + { + accessorKey: 'group', + header: t('Groups'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const group = row.getValue('group') as string + const groupArray = parseGroupsList(group) + return ( + ( + + ))} + /> + ) + }, + filterFn: (row, id, value) => { + if (!value || value.length === 0 || value.includes('all')) { + return true + } + const group = row.getValue(id) as string + const groupArray = parseGroupsList(group) + return groupArray.some((g) => value.includes(g)) + }, + size: 150, + enableSorting: false, + }, + + // Tag column + { + accessorKey: 'tag', + header: t('Tag'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const tag = row.getValue('tag') as string | null + if (!tag) { + return - + } + + return ( + + ) + }, + size: 120, + enableSorting: false, + }, + + // Priority column + { + accessorKey: 'priority', + header: t('Priority'), + meta: { mobileHidden: true }, + cell: ({ row }) => , + size: 100, + }, + + // Weight column + { + accessorKey: 'weight', + header: t('Weight'), + meta: { mobileHidden: true }, + cell: ({ row }) => , + size: 90, + enableSorting: false, + }, + + // Balance column (Used/Remaining) + { + accessorKey: 'balance', + header: t('Used / Remaining'), + cell: ({ row }) => , + size: 180, + }, + + // Response Time column + { + accessorKey: 'response_time', + header: t('Response'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const responseTime = row.getValue('response_time') as number + const config = getResponseTimeConfig(responseTime) + + return ( + ) - } + }, + size: 110, + }, - const type = row.getValue('type') as number - const typeNameKey = getChannelTypeLabel(type) - const typeName = t(typeNameKey) - const iconName = getChannelTypeIcon(type) - const channel = row.original as Channel - const isMultiKey = isMultiKeyChannel(channel) - const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random' - const MultiKeyModeIcon = - multiKeyMode === 'random' ? Shuffle : ListOrdered - const multiKeyTooltip = - multiKeyMode === 'random' - ? t('Multi-key: Random rotation') - : t('Multi-key: Polling rotation') + // Test Time column + { + accessorKey: 'test_time', + header: t('Last Tested'), + meta: { mobileHidden: true }, + cell: ({ row }) => { + const testTime = row.getValue('test_time') as number - const ionetMeta = parseIonetMeta(channel.other_info) - const isIonet = ionetMeta?.source === 'ionet' - const deploymentId = - typeof ionetMeta?.deployment_id === 'string' - ? ionetMeta?.deployment_id - : undefined + // For invalid timestamps, show "Never" badge + if (!testTime || testTime === 0) { + return - + } - return ( -
- {isMultiKey && ( - - - - } - > - - - {multiKeyTooltip} - - - )} - + const timeText = formatRelativeTime(testTime, locale) + const fullDate = formatTimestampToDate(testTime) + + // For valid timestamps, show tooltip with full date + return ( + + } - > - - - {typeName} + /> + +

{fullDate}

+
- {isIonet && ( - - - { - e.stopPropagation() - if (!deploymentId) { - return - } - const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}` - window.open(targetUrl, '_blank', 'noopener') - }} - /> - } - > - - - -
-
- {t('From IO.NET deployment')} -
- {deploymentId && ( -
- {t('Deployment ID')}: {deploymentId} -
- )} -
- {t('Click to open deployment')} -
-
-
-
-
- )} -
- ) - }, - filterFn: (row, id, value) => { - if (!value || value.length === 0 || value.includes('all')) { - return true - } - return value.includes(String(row.getValue(id))) - }, - size: 220, - enableSorting: false, - }, - - // Status column - { - accessorKey: 'status', - header: t('Status'), - meta: { mobileBadge: true }, - cell: ({ row }) => { - const isTagRow = isTagAggregateRow(row.original) - const status = row.getValue('status') as number - const channel = row.original as Channel - - // Tag row: show aggregated status - if (isTagRow) { - const childrenCount = (row.original as TagRow).children?.length || 0 - const hasEnabled = status === 1 - - if (hasEnabled) { - return ( - - ) - } else { - return ( - - ) - } - } - - // Regular channel row - const config = - CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] || - CHANNEL_STATUS_CONFIG[0] - - const isMultiKey = isMultiKeyChannel(channel) - const keySize = channel.channel_info?.multi_key_size ?? 0 - const disabledCount = channel.channel_info?.multi_key_status_list - ? Object.keys(channel.channel_info.multi_key_status_list).length - : 0 - const enabledCount = Math.max(0, keySize - disabledCount) - const label = - isMultiKey && keySize > 0 - ? `${t(config.label)} (${enabledCount}/${keySize})` - : t(config.label) - - // Auto-disabled: show reason and time tooltip - if (status === 3) { - let statusReason = '' - let statusTime = '' - try { - const otherInfo = channel.other_info - ? JSON.parse(channel.other_info) - : null - if (otherInfo) { - statusReason = otherInfo.status_reason || '' - statusTime = otherInfo.status_time - ? formatTimestampToDate(otherInfo.status_time) - : '' - } - } catch { - /* empty */ - } - - if (statusReason || statusTime) { - return ( - - - }> - - - -
- {statusReason && ( -
- {t('Reason:')} {statusReason} -
- )} - {statusTime && ( -
- {t('Time:')} {statusTime} -
- )} -
-
-
-
- ) - } - } - - return ( - - ) - }, - filterFn: (row, id, value) => { - if (!value || value.length === 0 || value.includes('all')) { - return true - } - const status = row.getValue(id) as number - if (value.includes('enabled')) { - return status === 1 - } - if (value.includes('disabled')) { - return status !== 1 - } - return false - }, - size: 120, - enableSorting: false, - }, - - // Models column - { - accessorKey: 'models', - header: t('Models'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const models = row.getValue('models') as string - const modelArray = parseModelsList(models) - return ( - ( - - ))} - /> - ) - }, - size: 200, - enableSorting: false, - }, - - // Group column - { - accessorKey: 'group', - header: t('Groups'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const group = row.getValue('group') as string - const groupArray = parseGroupsList(group) - return ( - ( - - ))} - /> - ) - }, - filterFn: (row, id, value) => { - if (!value || value.length === 0 || value.includes('all')) { - return true - } - const group = row.getValue(id) as string - const groupArray = parseGroupsList(group) - return groupArray.some((g) => value.includes(g)) - }, - size: 150, - enableSorting: false, - }, - - // Tag column - { - accessorKey: 'tag', - header: t('Tag'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const tag = row.getValue('tag') as string | null - if (!tag) { - return - - } - - return ( - - ) - }, - size: 120, - enableSorting: false, - }, - - // Priority column - { - accessorKey: 'priority', - header: t('Priority'), - meta: { mobileHidden: true }, - cell: ({ row }) => , - size: 100, - }, - - // Weight column - { - accessorKey: 'weight', - header: t('Weight'), - meta: { mobileHidden: true }, - cell: ({ row }) => , - size: 90, - enableSorting: false, - }, - - // Balance column (Used/Remaining) - { - accessorKey: 'balance', - header: t('Used / Remaining'), - cell: ({ row }) => , - size: 180, - }, - - // Response Time column - { - accessorKey: 'response_time', - header: t('Response'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const responseTime = row.getValue('response_time') as number - const config = getResponseTimeConfig(responseTime) - - return ( - - ) - }, - size: 110, - }, - - // Test Time column - { - accessorKey: 'test_time', - header: t('Last Tested'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const testTime = row.getValue('test_time') as number - - // For invalid timestamps, show "Never" badge - if (!testTime || testTime === 0) { - return - - } - - const timeText = formatRelativeTime(testTime, locale) - const fullDate = formatTimestampToDate(testTime) - - // For valid timestamps, show tooltip with full date - return ( - - - - } - /> - -

{fullDate}

-
-
-
- ) - }, - size: 120, - enableSorting: false, - }, - - // Actions column - { - id: 'actions', - header: () => t('Actions'), - cell: ({ row }) => { - // Check if this is a tag row (has children) - const isTagRow = isTagAggregateRow(row.original) - - if (isTagRow) { - return ( - ) - } - - return + }, + size: 120, + enableSorting: false, }, - size: 132, - enableSorting: false, - enableHiding: false, - meta: { pinned: 'right' as const }, - }, - ] + + // Actions column + { + id: 'actions', + header: () => t('Actions'), + cell: ({ row }) => { + // Check if this is a tag row (has children) + const isTagRow = isTagAggregateRow(row.original) + + if (isTagRow) { + return ( + + ) + } + + return + }, + size: 132, + enableSorting: false, + enableHiding: false, + meta: { pinned: 'right' as const }, + }, + ], + [t, locale, sensitiveVisible] + ) } diff --git a/web/default/src/features/channels/components/channels-provider.tsx b/web/default/src/features/channels/components/channels-provider.tsx index 6f4b9a07..a5db5579 100644 --- a/web/default/src/features/channels/components/channels-provider.tsx +++ b/web/default/src/features/channels/components/channels-provider.tsx @@ -17,7 +17,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ /* eslint-disable react-refresh/only-export-components */ -import React, { createContext, useContext, useState, useCallback } from 'react' +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, +} from 'react' import { useQueryClient } from '@tanstack/react-query' import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates' import { channelsQueryKeys } from '../lib' @@ -88,24 +94,38 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { }, [queryClient]) const upstream = useChannelUpstreamUpdates(refreshChannels) + // useState setters are stable, so the context value only needs to change when + // an actual state value changes. Memoizing avoids handing every consumer + // (including all channel cards/cells) a brand-new object on each render. + const value = useMemo( + () => ({ + open, + setOpen, + currentRow, + setCurrentRow, + currentTag, + setCurrentTag, + enableTagMode, + setEnableTagMode, + idSort, + setIdSort, + sensitiveVisible, + setSensitiveVisible, + upstream, + }), + [ + open, + currentRow, + currentTag, + enableTagMode, + idSort, + sensitiveVisible, + upstream, + ] + ) + return ( - + {children} ) diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index 9d22e494..7e002b76 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -371,7 +371,7 @@ export function ChannelsTable() { enableCardView viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY} renderCard={(row) => } - cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2 2xl:grid-cols-3' + cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3' applyHeaderSize toolbarProps={{ searchPlaceholder: t('Filter by name, ID, or key...'), diff --git a/web/default/src/features/channels/components/numeric-spinner-input.tsx b/web/default/src/features/channels/components/numeric-spinner-input.tsx index ebfd80b7..780d1416 100644 --- a/web/default/src/features/channels/components/numeric-spinner-input.tsx +++ b/web/default/src/features/channels/components/numeric-spinner-input.tsx @@ -164,8 +164,9 @@ export function NumericSpinnerInput({ type='button' onClick={handleStartEdit} disabled={disabled} + title={localValue} className={cn( - 'h-7 min-w-8 cursor-text px-1 text-center font-mono text-sm tabular-nums', + 'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums', disabled && 'cursor-default opacity-50' )} > diff --git a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts index e28c21d9..ab190de1 100644 --- a/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts +++ b/web/default/src/features/channels/hooks/use-channel-upstream-updates.ts @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useRef, useState, useCallback } from 'react' +import { useRef, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { api, type ApiRequestConfig } from '@/lib/api' @@ -285,20 +285,41 @@ export function useChannelUpstreamUpdates(refresh: () => Promise) { } }, [refresh, t]) - return { - showModal, - channel, - addModels, - removeModels, - preferredTab, - applyLoading, - detectAllLoading, - applyAllLoading, - openModal, - closeModal, - applyUpdates, - applyAllUpdates, - detectChannelUpdates, - detectAllUpdates, - } + // Memoized so consumers (and the channels context value built from this) get + // a stable reference unless an actual field changes. Callbacks above are all + // useCallback-stable, so this only changes when relevant state changes. + return useMemo( + () => ({ + showModal, + channel, + addModels, + removeModels, + preferredTab, + applyLoading, + detectAllLoading, + applyAllLoading, + openModal, + closeModal, + applyUpdates, + applyAllUpdates, + detectChannelUpdates, + detectAllUpdates, + }), + [ + showModal, + channel, + addModels, + removeModels, + preferredTab, + applyLoading, + detectAllLoading, + applyAllLoading, + openModal, + closeModal, + applyUpdates, + applyAllUpdates, + detectChannelUpdates, + detectAllUpdates, + ] + ) }