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:
+16
-10
@@ -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,19 +119,18 @@ 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. */}
|
||||||
|
<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('Priority')}</span>
|
||||||
<span className={labelClass}>{t('Weight')}</span>
|
<span className={labelClass}>{t('Weight')}</span>
|
||||||
<div className='flex justify-start'>{priorityCell}</div>
|
<div className='flex justify-start'>{priorityCell}</div>
|
||||||
<div className='flex justify-start'>{weightCell}</div>
|
<div className='flex justify-start'>{weightCell}</div>
|
||||||
</div>
|
<span className={cn('mt-2', labelClass)}>
|
||||||
<div className='grid grid-cols-2 gap-x-3'>
|
|
||||||
<div className={cn('mb-1', labelClass)}>
|
|
||||||
{fieldLabels.response_time}
|
{fieldLabels.response_time}
|
||||||
</div>
|
</span>
|
||||||
<div className={cn('mb-1', labelClass)}>{fieldLabels.test_time}</div>
|
<span className={cn('mt-2', labelClass)}>{fieldLabels.test_time}</span>
|
||||||
<div className='overflow-hidden text-sm'>
|
<div className='overflow-hidden text-sm'>
|
||||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +139,6 @@ export function ChannelCard({ row }: { row: Row<Channel> }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -27,7 +27,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
|
||||||
*/
|
*/
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -384,9 +384,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
|||||||
await handleUpdateChannelBalance(channel.id, queryClient)
|
await handleUpdateChannelBalance(channel.id, queryClient)
|
||||||
setIsUpdating(false)
|
setIsUpdating(false)
|
||||||
}
|
}
|
||||||
let remainingBadgeLabel = sensitiveVisible
|
let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
|
||||||
? remainingDisplay
|
|
||||||
: SENSITIVE_MASK
|
|
||||||
if (sensitiveVisible && isUpdating) {
|
if (sensitiveVisible && isUpdating) {
|
||||||
remainingBadgeLabel = t('Updating...')
|
remainingBadgeLabel = t('Updating...')
|
||||||
} else if (sensitiveVisible && channel.type === 57) {
|
} else if (sensitiveVisible && channel.type === 57) {
|
||||||
@@ -488,7 +486,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { sensitiveVisible } = useChannels()
|
const { sensitiveVisible } = useChannels()
|
||||||
const locale = i18n.resolvedLanguage || i18n.language
|
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
|
// Checkbox column
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -496,7 +499,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
indeterminate={table.getIsSomePageRowsSelected()}
|
indeterminate={table.getIsSomePageRowsSelected()}
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
aria-label='Select all'
|
aria-label='Select all'
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -696,7 +701,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
>
|
>
|
||||||
<MultiKeyModeIcon className='h-3 w-3' />
|
<MultiKeyModeIcon className='h-3 w-3' />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side='top'>{multiKeyTooltip}</TooltipContent>
|
<TooltipContent side='top'>
|
||||||
|
{multiKeyTooltip}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
@@ -817,8 +824,9 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
|
|
||||||
// Regular channel row
|
// Regular channel row
|
||||||
const config =
|
const config =
|
||||||
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
|
CHANNEL_STATUS_CONFIG[
|
||||||
CHANNEL_STATUS_CONFIG[0]
|
status as keyof typeof CHANNEL_STATUS_CONFIG
|
||||||
|
] || CHANNEL_STATUS_CONFIG[0]
|
||||||
|
|
||||||
const isMultiKey = isMultiKeyChannel(channel)
|
const isMultiKey = isMultiKeyChannel(channel)
|
||||||
const keySize = channel.channel_info?.multi_key_size ?? 0
|
const keySize = channel.channel_info?.multi_key_size ?? 0
|
||||||
@@ -1105,5 +1113,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
meta: { pinned: 'right' as const },
|
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
|
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,9 +94,11 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [queryClient])
|
}, [queryClient])
|
||||||
const upstream = useChannelUpstreamUpdates(refreshChannels)
|
const upstream = useChannelUpstreamUpdates(refreshChannels)
|
||||||
|
|
||||||
return (
|
// useState setters are stable, so the context value only needs to change when
|
||||||
<ChannelsContext.Provider
|
// an actual state value changes. Memoizing avoids handing every consumer
|
||||||
value={{
|
// (including all channel cards/cells) a brand-new object on each render.
|
||||||
|
const value = useMemo<ChannelsContextType>(
|
||||||
|
() => ({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
currentRow,
|
currentRow,
|
||||||
@@ -104,8 +112,20 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sensitiveVisible,
|
sensitiveVisible,
|
||||||
setSensitiveVisible,
|
setSensitiveVisible,
|
||||||
upstream,
|
upstream,
|
||||||
}}
|
}),
|
||||||
>
|
[
|
||||||
|
open,
|
||||||
|
currentRow,
|
||||||
|
currentTag,
|
||||||
|
enableTagMode,
|
||||||
|
idSort,
|
||||||
|
sensitiveVisible,
|
||||||
|
upstream,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChannelsContext.Provider value={value}>
|
||||||
{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,7 +285,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
}, [refresh, t])
|
}, [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,
|
showModal,
|
||||||
channel,
|
channel,
|
||||||
addModels,
|
addModels,
|
||||||
@@ -300,5 +304,22 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
|||||||
applyAllUpdates,
|
applyAllUpdates,
|
||||||
detectChannelUpdates,
|
detectChannelUpdates,
|
||||||
detectAllUpdates,
|
detectAllUpdates,
|
||||||
}
|
}),
|
||||||
|
[
|
||||||
|
showModal,
|
||||||
|
channel,
|
||||||
|
addModels,
|
||||||
|
removeModels,
|
||||||
|
preferredTab,
|
||||||
|
applyLoading,
|
||||||
|
detectAllLoading,
|
||||||
|
applyAllLoading,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
applyUpdates,
|
||||||
|
applyAllUpdates,
|
||||||
|
detectChannelUpdates,
|
||||||
|
detectAllUpdates,
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user