Commit f9e508bd by CaIon

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
parent cb841850
......@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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<Channel> }) {
function ChannelCardComponent({ row }: { row: Row<Channel> }) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original)
......@@ -118,19 +119,18 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div>
</div>
{/* Right column (sits on the right, content left-aligned) */}
<div className='flex flex-shrink-0 flex-col gap-3'>
<div className='grid grid-cols-2 items-center gap-x-3'>
{/* 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. */}
<div className='grid flex-shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
</div>
<div className='grid grid-cols-2 gap-x-3'>
<div className={cn('mb-1', labelClass)}>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</div>
<div className={cn('mb-1', labelClass)}>{fieldLabels.test_time}</div>
</span>
<span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
......@@ -139,7 +139,6 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div>
</div>
</div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'>
......@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div>
)
}
/**
* 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)
......@@ -27,7 +27,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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,7 +486,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const { t, i18n } = useTranslation()
const { sensitiveVisible } = useChannels()
const locale = i18n.resolvedLanguage || i18n.language
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<ColumnDef<Channel>[]>(
() => [
// Checkbox column
{
id: 'select',
......@@ -496,7 +499,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all'
/>
),
......@@ -696,7 +701,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
>
<MultiKeyModeIcon className='h-3 w-3' />
</TooltipTrigger>
<TooltipContent side='top'>{multiKeyTooltip}</TooltipContent>
<TooltipContent side='top'>
{multiKeyTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
......@@ -817,8 +824,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
// Regular channel row
const config =
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
CHANNEL_STATUS_CONFIG[0]
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
......@@ -1105,5 +1113,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
enableHiding: false,
meta: { pinned: 'right' as const },
},
]
],
[t, locale, sensitiveVisible]
)
}
......@@ -17,7 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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,9 +94,11 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
}, [queryClient])
const upstream = useChannelUpstreamUpdates(refreshChannels)
return (
<ChannelsContext.Provider
value={{
// 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<ChannelsContextType>(
() => ({
open,
setOpen,
currentRow,
......@@ -104,8 +112,20 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
sensitiveVisible,
setSensitiveVisible,
upstream,
}}
>
}),
[
open,
currentRow,
currentTag,
enableTagMode,
idSort,
sensitiveVisible,
upstream,
]
)
return (
<ChannelsContext.Provider value={value}>
{children}
</ChannelsContext.Provider>
)
......
......@@ -371,7 +371,7 @@ export function ChannelsTable() {
enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row) => <ChannelCard row={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...'),
......
......@@ -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'
)}
>
......
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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,7 +285,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
}
}, [refresh, t])
return {
// 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,
......@@ -300,5 +304,22 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
}
}),
[
showModal,
channel,
addModels,
removeModels,
preferredTab,
applyLoading,
detectAllLoading,
applyAllLoading,
openModal,
closeModal,
applyUpdates,
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
]
)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment