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
This commit is contained in:
+25
-19
@@ -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 = '••••'
|
|||||||
* 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> }) {
|
|||||||
</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> }) {
|
|||||||
</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)
|
||||||
|
|||||||
+584
-574
File diff suppressed because it is too large
Load Diff
@@ -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 }) {
|
|||||||
}, [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() {
|
|||||||
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({
|
|||||||
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/>.
|
|||||||
|
|
||||||
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>) {
|
|||||||
}
|
}
|
||||||
}, [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,
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user