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/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { memo } from 'react'
import { flexRender, type Row } from '@tanstack/react-table' import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
...@@ -36,7 +37,7 @@ const SENSITIVE_MASK = '••••' ...@@ -36,7 +37,7 @@ const SENSITIVE_MASK = '••••'
* priority/weight spinners, balance refresh, response/test times, tag * priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, and the per-row (or per-tag) actions menu. * 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 { t } = useTranslation()
const { sensitiveVisible } = useChannels() const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original) const isTagRow = isTagAggregateRow(row.original)
...@@ -118,25 +119,23 @@ export function ChannelCard({ row }: { row: Row<Channel> }) { ...@@ -118,25 +119,23 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div> </div>
</div> </div>
{/* Right column (sits on the right, content left-aligned) */} {/* Right column (sits on the right, content left-aligned). A single
<div className='flex flex-shrink-0 flex-col gap-3'> grid with content-sized columns keeps Priority/Weight and
<div className='grid grid-cols-2 items-center gap-x-3'> Response/Last Tested aligned without wasting horizontal space. */}
<span className={labelClass}>{t('Priority')}</span> <div className='grid flex-shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Weight')}</span> <span className={labelClass}>{t('Priority')}</span>
<div className='flex justify-start'>{priorityCell}</div> <span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{weightCell}</div> <div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</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> </div>
<div className='grid grid-cols-2 gap-x-3'> <div className='overflow-hidden text-sm'>
<div className={cn('mb-1', labelClass)}> {testCell ?? <span className='text-muted-foreground'>-</span>}
{fieldLabels.response_time}
</div>
<div className={cn('mb-1', labelClass)}>{fieldLabels.test_time}</div>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row<Channel> }) { ...@@ -161,3 +160,10 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
</div> </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)
...@@ -17,7 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
/* eslint-disable react-refresh/only-export-components */ /* 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 { useQueryClient } from '@tanstack/react-query'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates' import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib' import { channelsQueryKeys } from '../lib'
...@@ -88,24 +94,38 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { ...@@ -88,24 +94,38 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
}, [queryClient]) }, [queryClient])
const upstream = useChannelUpstreamUpdates(refreshChannels) 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<ChannelsContextType>(
() => ({
open,
setOpen,
currentRow,
setCurrentRow,
currentTag,
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sensitiveVisible,
setSensitiveVisible,
upstream,
}),
[
open,
currentRow,
currentTag,
enableTagMode,
idSort,
sensitiveVisible,
upstream,
]
)
return ( return (
<ChannelsContext.Provider <ChannelsContext.Provider value={value}>
value={{
open,
setOpen,
currentRow,
setCurrentRow,
currentTag,
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
sensitiveVisible,
setSensitiveVisible,
upstream,
}}
>
{children} {children}
</ChannelsContext.Provider> </ChannelsContext.Provider>
) )
......
...@@ -371,7 +371,7 @@ export function ChannelsTable() { ...@@ -371,7 +371,7 @@ export function ChannelsTable() {
enableCardView enableCardView
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY} viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
renderCard={(row) => <ChannelCard row={row} />} 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 applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'), searchPlaceholder: t('Filter by name, ID, or key...'),
......
...@@ -164,8 +164,9 @@ export function NumericSpinnerInput({ ...@@ -164,8 +164,9 @@ export function NumericSpinnerInput({
type='button' type='button'
onClick={handleStartEdit} onClick={handleStartEdit}
disabled={disabled} disabled={disabled}
title={localValue}
className={cn( 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' disabled && 'cursor-default opacity-50'
)} )}
> >
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api, type ApiRequestConfig } from '@/lib/api' import { api, type ApiRequestConfig } from '@/lib/api'
...@@ -285,20 +285,41 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) { ...@@ -285,20 +285,41 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
} }
}, [refresh, t]) }, [refresh, t])
return { // Memoized so consumers (and the channels context value built from this) get
showModal, // a stable reference unless an actual field changes. Callbacks above are all
channel, // useCallback-stable, so this only changes when relevant state changes.
addModels, return useMemo(
removeModels, () => ({
preferredTab, showModal,
applyLoading, channel,
detectAllLoading, addModels,
applyAllLoading, removeModels,
openModal, preferredTab,
closeModal, applyLoading,
applyUpdates, detectAllLoading,
applyAllUpdates, applyAllLoading,
detectChannelUpdates, openModal,
detectAllUpdates, closeModal,
} applyUpdates,
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