mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-12 05:32:09 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf8879a81 | ||
|
|
c576122a4c |
@@ -29,6 +29,7 @@
|
||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
||||
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
||||
- 修复名称含空格的策略组在延迟检测后未更新排序和筛选
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
|
||||
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
||||
- 优化代理页面和首页节点卡片,减少延迟更新时的卡顿
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
@@ -204,14 +205,11 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
|
||||
|
||||
const sortProxyOptions = (
|
||||
options: ProxyOption[],
|
||||
sortType: ProxySortType,
|
||||
sortType: 0 | 1,
|
||||
delayOf: (member: ResolvedProxyMember) => number,
|
||||
latencyTimeout?: number,
|
||||
) => {
|
||||
if (sortType === 0) return options
|
||||
if (sortType === 2) {
|
||||
return [...options].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
const effectiveTimeout =
|
||||
typeof latencyTimeout === 'number' && latencyTimeout > 0
|
||||
@@ -433,6 +431,72 @@ interface PersistentProxySelectProps
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ProxyMenuOption = memo(function ProxyMenuOption({
|
||||
option,
|
||||
selected,
|
||||
isFixed,
|
||||
fixedProxyInUsed,
|
||||
delay,
|
||||
onChange,
|
||||
}: {
|
||||
option: ProxyOption
|
||||
selected: boolean
|
||||
isFixed: boolean
|
||||
fixedProxyInUsed: boolean
|
||||
delay: number
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<MenuItem
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
selected={selected}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (!option.disabled) onChange(option.value)
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
{isFixed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-5px',
|
||||
right: '5px',
|
||||
...(!fixedProxyInUsed && {
|
||||
filter: 'grayscale(1)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{!option.disabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delay)}
|
||||
color={convertDelayColor(delay)}
|
||||
sx={{
|
||||
minWidth: '60px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
|
||||
const PersistentProxySelect = ({
|
||||
groupName,
|
||||
fixed,
|
||||
@@ -485,54 +549,15 @@ const PersistentProxySelect = ({
|
||||
: delayManager.getDelayFix(option.member, groupName)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
<ProxyMenuOption
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
option={option}
|
||||
selected={selected}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
if (!option.disabled) onChange(option.value)
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
{isFixed && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
fontSize: '12px',
|
||||
top: '-5px',
|
||||
right: '5px',
|
||||
...(!fixedProxyInUsed && {
|
||||
filter: 'grayscale(1)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{!option.disabled && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delay)}
|
||||
color={convertDelayColor(delay)}
|
||||
sx={{
|
||||
minWidth: '60px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
isFixed={isFixed}
|
||||
fixedProxyInUsed={fixedProxyInUsed}
|
||||
delay={delay}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -605,12 +630,14 @@ export const CurrentProxyCard = () => {
|
||||
[getProfileStorageKey],
|
||||
)
|
||||
|
||||
const { select } = useProxySelection({
|
||||
onError: (error) => {
|
||||
const onSelectionError = useCallback(
|
||||
(error: unknown) => {
|
||||
console.error('代理切换失败', error)
|
||||
refreshProxy()
|
||||
},
|
||||
})
|
||||
[refreshProxy],
|
||||
)
|
||||
const { select } = useProxySelection({ onError: onSelectionError })
|
||||
|
||||
const mode = clashConfig?.mode?.toLowerCase() || 'rule'
|
||||
const isGlobalMode = mode === 'global'
|
||||
@@ -673,7 +700,6 @@ export const CurrentProxyCard = () => {
|
||||
if (proxyView && lastGroupName !== selectedGroupName) {
|
||||
setLastGroupName(selectedGroupName)
|
||||
}
|
||||
const delays = useGroupDelays(selectedGroupName || null)
|
||||
|
||||
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
|
||||
if (!proxyView || isDirectMode) return null
|
||||
@@ -767,10 +793,17 @@ export const CurrentProxyCard = () => {
|
||||
const currentProxy = currentMember ? memberDetails(currentMember) : undefined
|
||||
const selectedProxyName = currentMember?.ref.name ?? ''
|
||||
|
||||
const delays = useGroupDelays(
|
||||
selectedGroupName,
|
||||
currentMember,
|
||||
openSelect === 'proxy',
|
||||
)
|
||||
const currentDelay =
|
||||
currentMember && selectedGroupName
|
||||
? delayManager.getDelayFix(currentMember, selectedGroupName)
|
||||
: -1
|
||||
typeof delays === 'number'
|
||||
? delays
|
||||
: currentMember
|
||||
? delays.of(currentMember)
|
||||
: -1
|
||||
|
||||
const signalInfo =
|
||||
currentProxy && selectedGroupName
|
||||
@@ -890,18 +923,28 @@ export const CurrentProxyCard = () => {
|
||||
refreshProxy()
|
||||
})
|
||||
|
||||
const nameSortedProxyOptions = useMemo(
|
||||
() =>
|
||||
!isDirectMode && openSelect === 'proxy' && sortType === 2
|
||||
? [...unsortedProxyOptions].sort((a, b) => a.name.localeCompare(b.name))
|
||||
: unsortedProxyOptions,
|
||||
[isDirectMode, openSelect, sortType, unsortedProxyOptions],
|
||||
)
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
isDirectMode || openSelect !== 'proxy'
|
||||
isDirectMode || openSelect !== 'proxy' || typeof delays === 'number'
|
||||
? []
|
||||
: sortProxyOptions(
|
||||
unsortedProxyOptions,
|
||||
sortType,
|
||||
delays.of,
|
||||
defaultLatencyTimeout!,
|
||||
),
|
||||
: sortType === 2
|
||||
? nameSortedProxyOptions
|
||||
: sortProxyOptions(
|
||||
unsortedProxyOptions,
|
||||
sortType,
|
||||
delays.of,
|
||||
defaultLatencyTimeout!,
|
||||
),
|
||||
[
|
||||
delays,
|
||||
nameSortedProxyOptions,
|
||||
isDirectMode,
|
||||
openSelect,
|
||||
unsortedProxyOptions,
|
||||
|
||||
@@ -469,12 +469,14 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
}
|
||||
}, [handleScroll])
|
||||
|
||||
const { select } = useProxySelection({
|
||||
onError: (error) => {
|
||||
const onSelectionError = useCallback(
|
||||
(error: unknown) => {
|
||||
console.error('代理切换失败', error)
|
||||
onProxies()
|
||||
},
|
||||
})
|
||||
[onProxies],
|
||||
)
|
||||
const { select } = useProxySelection({ onError: onSelectionError })
|
||||
|
||||
const handleChangeProxy = useCallback(
|
||||
(group: ProxyGroupView, member: ResolvedProxyMember) => {
|
||||
@@ -569,6 +571,17 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
[renderList],
|
||||
)
|
||||
|
||||
const onStickyHeadState = useCallback(
|
||||
async (groupName: string, patch: Partial<HeadState>) => {
|
||||
if (patch.filterText !== undefined) {
|
||||
handleGroupLocationByName(groupName)
|
||||
await stickyListRef.current?.waitForScrollEnd()
|
||||
}
|
||||
onHeadState(groupName, patch)
|
||||
},
|
||||
[handleGroupLocationByName, onHeadState],
|
||||
)
|
||||
|
||||
const renderGroupItem = useCallback(
|
||||
(item: IRenderItem, _index: number, stickyed: boolean) => (
|
||||
<ProxyRender
|
||||
@@ -576,13 +589,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
stickyed={stickyed}
|
||||
onLocation={handleLocation}
|
||||
onCheckAll={handleCheckAll}
|
||||
onHeadState={async (groupName, patch) => {
|
||||
if (stickyed && patch.filterText !== undefined) {
|
||||
handleGroupLocationByName(groupName)
|
||||
await stickyListRef.current?.waitForScrollEnd()
|
||||
}
|
||||
onHeadState(groupName, patch)
|
||||
}}
|
||||
onHeadState={stickyed ? onStickyHeadState : onHeadState}
|
||||
onChangeProxy={handleChangeProxy}
|
||||
onGroupToggle={handleGroupToggle}
|
||||
/>
|
||||
@@ -593,7 +600,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
onHeadState,
|
||||
handleLocation,
|
||||
handleGroupToggle,
|
||||
handleGroupLocationByName,
|
||||
onStickyHeadState,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIconCache } from '@/hooks/use-icon-cache'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { ProxyGroupView } from '@/types/proxy-view'
|
||||
|
||||
import { ProxyRender } from './proxy-render'
|
||||
import { DEFAULT_STATE } from './use-head-state'
|
||||
|
||||
vi.mock('@/hooks/use-verge', () => ({
|
||||
useVerge: vi.fn(() => ({ verge: {} })),
|
||||
}))
|
||||
vi.mock('@/hooks/use-icon-cache', () => ({
|
||||
useIconCache: vi.fn(() => ''),
|
||||
}))
|
||||
vi.mock('@/services/states', () => ({
|
||||
useThemeMode: vi.fn(() => 'light'),
|
||||
}))
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
vi.mock('./proxy-item', () => ({ ProxyItem: () => null }))
|
||||
vi.mock('./proxy-group-tools', () => ({ ProxyGroupTools: () => null }))
|
||||
vi.mock('./proxy-group-header-block', () => ({ ProxyGroupHeaderBlock: 'div' }))
|
||||
|
||||
it('keeps header-only consumers off 500 mounted member rows', () => {
|
||||
const group: ProxyGroupView = {
|
||||
name: 'group',
|
||||
type: 'Selector',
|
||||
history: [],
|
||||
members: [],
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
}
|
||||
const noop = () => {}
|
||||
for (let i = 0; i <= 500; i++) {
|
||||
renderToStaticMarkup(
|
||||
createElement(ProxyRender, {
|
||||
item: {
|
||||
type: i === 0 ? 0 : 2,
|
||||
key: `${i}`,
|
||||
group,
|
||||
headState: DEFAULT_STATE,
|
||||
member: {
|
||||
memberIndex: i,
|
||||
member: {
|
||||
kind: 'unresolved',
|
||||
ref: { kind: 'unresolved', name: `${i}`, reason: 'missing' },
|
||||
},
|
||||
},
|
||||
},
|
||||
onLocation: noop,
|
||||
onCheckAll: noop,
|
||||
onHeadState: noop,
|
||||
onChangeProxy: noop,
|
||||
}),
|
||||
)
|
||||
}
|
||||
expect(useVerge).toHaveBeenCalledTimes(1)
|
||||
expect(useThemeMode).toHaveBeenCalledTimes(1)
|
||||
expect(useIconCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -43,7 +43,7 @@ interface RenderProps {
|
||||
onGroupToggle?: (group: IRenderItem['group']) => void
|
||||
}
|
||||
|
||||
export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
const ProxyGroupHeader = (props: RenderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
item,
|
||||
@@ -51,11 +51,9 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
onLocation,
|
||||
onCheckAll,
|
||||
onHeadState,
|
||||
onChangeProxy,
|
||||
onGroupToggle,
|
||||
isChainMode: _ = false,
|
||||
} = props
|
||||
const { type, group, headState, member, memberCol } = item
|
||||
const { group, headState } = item
|
||||
const { verge } = useVerge()
|
||||
const enable_group_icon = verge?.enable_group_icon ?? true
|
||||
const toolsOnLeft = verge?.proxy_group_tools_position === 'left'
|
||||
@@ -69,11 +67,187 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
enabled: enable_group_icon,
|
||||
})
|
||||
|
||||
const nameBlock = (
|
||||
<ProxyGroupHeaderBlock
|
||||
key="name"
|
||||
group={headerId}
|
||||
block="name"
|
||||
sx={{ flex: '0 1 auto', mr: toolsOnLeft ? 1 : 0 }}
|
||||
>
|
||||
{enable_group_icon && group.icon?.trim().startsWith('http') && (
|
||||
<img
|
||||
src={iconCachePath === '' ? group.icon : iconCachePath}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
style={{ marginRight: '12px', borderRadius: '6px' }}
|
||||
/>
|
||||
)}
|
||||
{enable_group_icon && group.icon?.trim().startsWith('data') && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
style={{ marginRight: '12px', borderRadius: '6px' }}
|
||||
/>
|
||||
)}
|
||||
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
|
||||
<img
|
||||
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(group.icon)}`}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
/>
|
||||
)}
|
||||
<ListItemText
|
||||
sx={{ flex: '0 1 auto', minWidth: 0 }}
|
||||
primary={<StyledPrimary>{group.name}</StyledPrimary>}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pt: '2px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<StyledTypeBox>{group.type}</StyledTypeBox>
|
||||
<StyledSubtitle sx={{ color: 'text.secondary' }}>
|
||||
{group.now}
|
||||
</StyledSubtitle>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
slotProps={{
|
||||
secondary: {
|
||||
component: 'div',
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#ccc',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ProxyGroupHeaderBlock>
|
||||
)
|
||||
|
||||
const proxyCount = (
|
||||
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
|
||||
<div
|
||||
style={{
|
||||
minWidth: '50px',
|
||||
display: 'flex',
|
||||
justifyContent: toolsOnLeft ? 'start' : 'end',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${group.members.length}`}
|
||||
sx={{
|
||||
mr: toolsOnLeft ? 0 : 1,
|
||||
backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.1),
|
||||
color: (theme) => theme.palette.primary.main,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const toolsBlock = (
|
||||
<ProxyGroupHeaderBlock
|
||||
key="tools"
|
||||
group={headerId}
|
||||
block="tools"
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
justifyContent: toolsOnLeft ? 'start' : 'end',
|
||||
mr: toolsOnLeft ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{toolsOnLeft && proxyCount}
|
||||
<ProxyGroupTools
|
||||
side={toolsOnLeft ? 'left' : 'right'}
|
||||
url={group.testUrl!}
|
||||
groupName={group.name}
|
||||
headState={headState!}
|
||||
onLocation={() => onLocation(group)}
|
||||
onCheckDelay={() => onCheckAll(group.name)}
|
||||
onHeadState={(p) => onHeadState(group.name, p)}
|
||||
/>
|
||||
{!toolsOnLeft && proxyCount}
|
||||
</ProxyGroupHeaderBlock>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<ListItemButton
|
||||
dense
|
||||
sx={{
|
||||
boxShadow:
|
||||
stickyed && headState?.open
|
||||
? '0 4px 8px rgba(0, 0, 0, 0.2) !important'
|
||||
: undefined,
|
||||
}}
|
||||
style={{
|
||||
background: itembackgroundcolor,
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (headState?.open) {
|
||||
onGroupToggle?.(group)
|
||||
}
|
||||
onHeadState?.(group.name, { open: !headState?.open })
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
{toolsOnLeft ? toolsBlock : nameBlock}
|
||||
{toolsOnLeft ? nameBlock : toolsBlock}
|
||||
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProxyEmptyRow = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
py: 2,
|
||||
pl: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<InboxRounded sx={{ fontSize: '2.5em', color: 'inherit' }} />
|
||||
<Typography sx={{ color: 'inherit' }}>
|
||||
{t('proxies.page.empty.noProxies')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
const { item, onLocation, onCheckAll, onHeadState, onChangeProxy } = props
|
||||
const { type, group, headState, member, memberCol } = item
|
||||
const showType = headState?.showType
|
||||
const memberColItemsMemo = useMemo(() => {
|
||||
if (type !== 4 || !memberCol) {
|
||||
return null
|
||||
}
|
||||
if (type !== 4 || !memberCol) return null
|
||||
|
||||
return memberCol.map((occurrence) => (
|
||||
<ProxyItemMini
|
||||
@@ -87,161 +261,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
))
|
||||
}, [type, memberCol, item.key, group, showType, onChangeProxy])
|
||||
|
||||
if (type === 0) {
|
||||
const nameBlock = (
|
||||
<ProxyGroupHeaderBlock
|
||||
key="name"
|
||||
group={headerId}
|
||||
block="name"
|
||||
sx={{ flex: '0 1 auto', mr: toolsOnLeft ? 1 : 0 }}
|
||||
>
|
||||
{enable_group_icon && group.icon?.trim().startsWith('http') && (
|
||||
<img
|
||||
src={iconCachePath === '' ? group.icon : iconCachePath}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
style={{ marginRight: '12px', borderRadius: '6px' }}
|
||||
/>
|
||||
)}
|
||||
{enable_group_icon && group.icon?.trim().startsWith('data') && (
|
||||
<img
|
||||
src={group.icon}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
style={{ marginRight: '12px', borderRadius: '6px' }}
|
||||
/>
|
||||
)}
|
||||
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
|
||||
<img
|
||||
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(group.icon)}`}
|
||||
alt={group.name}
|
||||
width="32px"
|
||||
/>
|
||||
)}
|
||||
<ListItemText
|
||||
sx={{ flex: '0 1 auto', minWidth: 0 }}
|
||||
primary={<StyledPrimary>{group.name}</StyledPrimary>}
|
||||
secondary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pt: '2px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<StyledTypeBox>{group.type}</StyledTypeBox>
|
||||
<StyledSubtitle sx={{ color: 'text.secondary' }}>
|
||||
{group.now}
|
||||
</StyledSubtitle>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
slotProps={{
|
||||
secondary: {
|
||||
component: 'div',
|
||||
sx: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#ccc',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ProxyGroupHeaderBlock>
|
||||
)
|
||||
|
||||
const proxyCount = (
|
||||
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
|
||||
<div
|
||||
style={{
|
||||
minWidth: '50px',
|
||||
display: 'flex',
|
||||
justifyContent: toolsOnLeft ? 'start' : 'end',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${group.members.length}`}
|
||||
sx={{
|
||||
mr: toolsOnLeft ? 0 : 1,
|
||||
backgroundColor: (theme) =>
|
||||
alpha(theme.palette.primary.main, 0.1),
|
||||
color: (theme) => theme.palette.primary.main,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const toolsBlock = (
|
||||
<ProxyGroupHeaderBlock
|
||||
key="tools"
|
||||
group={headerId}
|
||||
block="tools"
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
justifyContent: toolsOnLeft ? 'start' : 'end',
|
||||
mr: toolsOnLeft ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{toolsOnLeft && proxyCount}
|
||||
<ProxyGroupTools
|
||||
side={toolsOnLeft ? 'left' : 'right'}
|
||||
url={group.testUrl!}
|
||||
groupName={group.name}
|
||||
headState={headState!}
|
||||
onLocation={() => onLocation(group)}
|
||||
onCheckDelay={() => onCheckAll(group.name)}
|
||||
onHeadState={(p) => onHeadState(group.name, p)}
|
||||
/>
|
||||
{!toolsOnLeft && proxyCount}
|
||||
</ProxyGroupHeaderBlock>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<ListItemButton
|
||||
dense
|
||||
sx={{
|
||||
boxShadow:
|
||||
stickyed && headState?.open
|
||||
? '0 4px 8px rgba(0, 0, 0, 0.2) !important'
|
||||
: undefined,
|
||||
}}
|
||||
style={{
|
||||
background: itembackgroundcolor,
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (headState?.open) {
|
||||
onGroupToggle?.(group)
|
||||
}
|
||||
onHeadState?.(group.name, { open: !headState?.open })
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
{toolsOnLeft ? toolsBlock : nameBlock}
|
||||
{toolsOnLeft ? nameBlock : toolsBlock}
|
||||
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (type === 0) return <ProxyGroupHeader {...props} />
|
||||
|
||||
if (type === 1) {
|
||||
return (
|
||||
@@ -271,23 +291,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
py: 2,
|
||||
pl: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<InboxRounded sx={{ fontSize: '2.5em', color: 'inherit' }} />
|
||||
<Typography sx={{ color: 'inherit' }}>
|
||||
{t('proxies.page.empty.noProxies')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
return <ProxyEmptyRow />
|
||||
}
|
||||
|
||||
if (type === 4) {
|
||||
|
||||
@@ -29,6 +29,9 @@ export function filterSort(
|
||||
const regex1 = /delay([=<>])(\d+|timeout|error)/i
|
||||
const regex2 = /type=(.*)/i
|
||||
|
||||
export const isDelayFilter = (filterText: string) =>
|
||||
regex1.test(filterText.trim())
|
||||
|
||||
function filterProxies(
|
||||
proxies: ResolvedMemberOccurrence[],
|
||||
groupName: string,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DelaySnapshot } from '@/services/delay'
|
||||
import { resolveMember, type ProxyViewV1 } from '@/types/proxy-view'
|
||||
|
||||
import { filterSort } from './use-filter-sort'
|
||||
import { useRenderList as readRenderList } from './use-render-list'
|
||||
|
||||
const hooks = vi.hoisted(() => {
|
||||
const slots: { value: unknown; deps?: unknown[] }[] = []
|
||||
let cursor = 0
|
||||
return {
|
||||
reset: () => {
|
||||
cursor = 0
|
||||
},
|
||||
memo: (factory: () => unknown, deps: unknown[]) => {
|
||||
const index = cursor++
|
||||
const old = slots[index]
|
||||
if (!old || deps.some((dep, i) => !Object.is(dep, old.deps?.[i]))) {
|
||||
slots[index] = { value: factory(), deps }
|
||||
}
|
||||
return slots[index].value
|
||||
},
|
||||
ref: (current: unknown) => {
|
||||
const index = cursor++
|
||||
slots[index] ??= { value: { current } }
|
||||
return slots[index].value
|
||||
},
|
||||
}
|
||||
})
|
||||
const state = vi.hoisted(() => ({
|
||||
view: null as ProxyViewV1 | null,
|
||||
delays: new Map<string, DelaySnapshot>(),
|
||||
}))
|
||||
|
||||
vi.mock('react', () => ({
|
||||
useMemo: hooks.memo,
|
||||
useRef: hooks.ref,
|
||||
useEffect: () => {},
|
||||
}))
|
||||
vi.mock('@/hooks/use-profiles', () => ({}))
|
||||
vi.mock('@/hooks/use-clash', () => ({
|
||||
useRuntimeConfig: () => ({ data: null }),
|
||||
}))
|
||||
vi.mock('@/hooks/use-verge', () => ({
|
||||
useVerge: () => ({ verge: { proxy_layout_column: 1 } }),
|
||||
}))
|
||||
vi.mock('@/providers/app-data-context', () => ({
|
||||
useProxiesData: () => ({ proxyView: state.view }),
|
||||
useAppRefreshers: () => ({ refreshProxy: () => {} }),
|
||||
}))
|
||||
vi.mock('@/hooks/use-group-delays', () => ({
|
||||
useGroupsDelays: (names: string[]) =>
|
||||
new Map(names.map((name) => [name, state.delays.get(name)])),
|
||||
}))
|
||||
vi.mock('./use-window-width', () => ({
|
||||
useWindowWidth: () => ({ width: 800 }),
|
||||
}))
|
||||
vi.mock('./use-head-state', async (original) => {
|
||||
const actual = await original<typeof import('./use-head-state')>()
|
||||
const heads = {
|
||||
plain: { ...actual.DEFAULT_STATE, open: true },
|
||||
sorted: { ...actual.DEFAULT_STATE, open: true, sortType: 1 },
|
||||
}
|
||||
return { ...actual, useHeadStateNew: () => [heads, () => {}] }
|
||||
})
|
||||
vi.mock('./use-filter-sort', async (original) => {
|
||||
const actual = await original<typeof import('./use-filter-sort')>()
|
||||
return { ...actual, filterSort: vi.fn(actual.filterSort) }
|
||||
})
|
||||
vi.mock('@/types/proxy-view', async (original) => {
|
||||
const actual = await original<typeof import('@/types/proxy-view')>()
|
||||
return { ...actual, resolveMember: vi.fn(actual.resolveMember) }
|
||||
})
|
||||
|
||||
it('skips delay-independent projections and reuses members for delay sorting', () => {
|
||||
const group = (name: string) => ({
|
||||
name,
|
||||
type: 'Selector',
|
||||
history: [],
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
members: Array.from({ length: 500 }, (_, i) => ({
|
||||
kind: 'unresolved' as const,
|
||||
name: `${i}`,
|
||||
reason: 'missing' as const,
|
||||
})),
|
||||
})
|
||||
state.view = {
|
||||
schemaVersion: 1,
|
||||
orderSource: 'runtime',
|
||||
providerState: 'ready',
|
||||
global: null,
|
||||
direct: null,
|
||||
groups: [group('plain'), group('sorted')],
|
||||
records: {},
|
||||
standalone: [],
|
||||
providers: [],
|
||||
}
|
||||
const render = () => {
|
||||
hooks.reset()
|
||||
return readRenderList('rule')
|
||||
}
|
||||
state.delays.set('plain', { of: () => 42 })
|
||||
state.delays.set('sorted', { of: () => 42 })
|
||||
const initial = render().renderList
|
||||
vi.mocked(filterSort).mockClear()
|
||||
vi.mocked(resolveMember).mockClear()
|
||||
state.delays.set('plain', { of: () => 43 })
|
||||
const flatMap = vi.spyOn(Array.prototype, 'flatMap')
|
||||
const flat = vi.spyOn(Array.prototype, 'flat')
|
||||
const unrelated = render().renderList
|
||||
expect(filterSort).toHaveBeenCalledTimes(0)
|
||||
expect(flatMap.mock.calls.length + flat.mock.calls.length).toBe(0)
|
||||
expect(unrelated.filter((item, i) => item !== initial[i])).toHaveLength(0)
|
||||
state.delays.set('sorted', { of: () => 43 })
|
||||
const settled = render().renderList
|
||||
expect(filterSort).toHaveBeenCalledTimes(1)
|
||||
expect(resolveMember).toHaveBeenCalledTimes(0)
|
||||
expect(settled.filter((item, i) => item !== initial[i])).toHaveLength(0)
|
||||
flatMap.mockRestore()
|
||||
flat.mockRestore()
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/types/proxy-view'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
import { filterSort } from './use-filter-sort'
|
||||
import { filterSort, isDelayFilter } from './use-filter-sort'
|
||||
import {
|
||||
DEFAULT_STATE,
|
||||
useHeadStateNew,
|
||||
@@ -60,14 +60,15 @@ export const hasRenderableItems = (
|
||||
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
|
||||
|
||||
type GroupCache = {
|
||||
now: string | undefined
|
||||
members: ProxyGroupView['members']
|
||||
group: ProxyGroupView
|
||||
headState: HeadState
|
||||
col: number
|
||||
latencyTimeout: number | undefined
|
||||
/// This group's own delays. Compared by identity so that a test settling in one group
|
||||
/// does not throw away every other group's sorted order.
|
||||
delays: DelaySnapshot | undefined
|
||||
occurrences: ResolvedMemberOccurrence[] | undefined
|
||||
projected: ResolvedMemberOccurrence[] | undefined
|
||||
items: IRenderItem[]
|
||||
}
|
||||
|
||||
@@ -222,19 +223,55 @@ export const useRenderList = (
|
||||
return current?.kind === 'group' ? [global, current.group] : [global]
|
||||
}, [mode, proxyView])
|
||||
|
||||
// Every group this list draws, so a test settling in any of them re-sorts that group.
|
||||
const repeatedNameGroups = useMemo(() => {
|
||||
const repeated = new Set<string>()
|
||||
if (isChainMode) return repeated
|
||||
for (const group of renderGroups) {
|
||||
const names = new Set<string>()
|
||||
if (
|
||||
group.members.some(({ name }) => {
|
||||
if (names.has(name)) return true
|
||||
names.add(name)
|
||||
return false
|
||||
})
|
||||
) {
|
||||
repeated.add(group.name)
|
||||
}
|
||||
}
|
||||
return repeated
|
||||
}, [isChainMode, renderGroups])
|
||||
|
||||
// Rows own delay updates unless repeated names share the current item listener.
|
||||
const renderedGroupNames = useMemo(() => {
|
||||
if (!proxyView) return []
|
||||
if (isChainMode)
|
||||
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
||||
return renderGroups.map(({ name }) => name)
|
||||
}, [isChainMode, proxyView, renderGroups, selectedGroup])
|
||||
if (!proxyView || isChainMode) return []
|
||||
return renderGroups
|
||||
.filter((group) => {
|
||||
const state = headStates[group.name] || DEFAULT_STATE
|
||||
return (
|
||||
(state.open || group === proxyView.global) &&
|
||||
(state.sortType === 1 ||
|
||||
isDelayFilter(state.filterText) ||
|
||||
repeatedNameGroups.has(group.name))
|
||||
)
|
||||
})
|
||||
.map(({ name }) => name)
|
||||
}, [headStates, isChainMode, proxyView, renderGroups, repeatedNameGroups])
|
||||
const groupDelays = useGroupsDelays(renderedGroupNames)
|
||||
|
||||
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
||||
const groupCacheRef = useRef<Map<string, GroupCache> | null>(null)
|
||||
const cachedViewRef = useRef(proxyView)
|
||||
const prevListRef = useRef<IRenderItem[]>([])
|
||||
const prevGroupItemsRef = useRef<IRenderItem[][]>([])
|
||||
const prevModeRef = useRef(mode)
|
||||
|
||||
const renderList = useMemo<IRenderItem[]>(() => {
|
||||
const cache = (groupCacheRef.current ??= new Map())
|
||||
if (cachedViewRef.current !== proxyView) {
|
||||
cache.clear()
|
||||
prevGroupItemsRef.current = []
|
||||
prevListRef.current = []
|
||||
cachedViewRef.current = proxyView
|
||||
}
|
||||
if (!proxyView) return []
|
||||
|
||||
if (isChainMode) {
|
||||
@@ -269,16 +306,13 @@ export const useRenderList = (
|
||||
}))
|
||||
}
|
||||
|
||||
const cache = groupCacheRef.current
|
||||
|
||||
const retList = renderGroups.flatMap((group) => {
|
||||
const groupItems = renderGroups.map((group) => {
|
||||
const isFlat = group === proxyView.global
|
||||
const headState = headStates[group.name] || DEFAULT_STATE
|
||||
const cached = cache.get(group.name)
|
||||
if (
|
||||
cached &&
|
||||
cached.now === group.now &&
|
||||
cached.members === group.members &&
|
||||
cached.group === group &&
|
||||
cached.headState === headState &&
|
||||
cached.col === col &&
|
||||
cached.latencyTimeout === latencyTimeout &&
|
||||
@@ -287,6 +321,51 @@ export const useRenderList = (
|
||||
return cached.items
|
||||
}
|
||||
|
||||
// Duplicate names share one item listener until the delay-store fix lands.
|
||||
const refreshRepeated =
|
||||
repeatedNameGroups.has(group.name) &&
|
||||
cached?.delays !== groupDelays.get(group.name)
|
||||
let resolved =
|
||||
cached?.group === group && !refreshRepeated
|
||||
? cached.occurrences
|
||||
: undefined
|
||||
let projected: ResolvedMemberOccurrence[] | undefined
|
||||
if (headState.open || isFlat) {
|
||||
resolved ??= resolveOccurrences(proxyView, group)
|
||||
projected = filterSort(
|
||||
resolved,
|
||||
group.name,
|
||||
headState.filterText,
|
||||
headState.sortType,
|
||||
latencyTimeout!,
|
||||
{
|
||||
matchCase: headState.filterMatchCase,
|
||||
matchWholeWord: headState.filterMatchWholeWord,
|
||||
useRegularExpression: headState.filterUseRegularExpression,
|
||||
},
|
||||
)
|
||||
// Repeated names still need new member props while item listeners share a key.
|
||||
if (
|
||||
!refreshRepeated &&
|
||||
cached?.group === group &&
|
||||
cached.headState === headState &&
|
||||
cached.col === col &&
|
||||
cached.projected?.length === projected.length &&
|
||||
projected.every(
|
||||
(member, index) => member === cached.projected?.[index],
|
||||
)
|
||||
) {
|
||||
cache.set(group.name, {
|
||||
...cached,
|
||||
latencyTimeout,
|
||||
delays: groupDelays.get(group.name),
|
||||
})
|
||||
return cached.items
|
||||
}
|
||||
} else {
|
||||
resolved = undefined
|
||||
}
|
||||
|
||||
const ret: IRenderItem[] = [
|
||||
isFlat
|
||||
? { type: 1, key: `head-${group.name}`, group, headState }
|
||||
@@ -299,20 +378,8 @@ export const useRenderList = (
|
||||
testUrl: group.testUrl!,
|
||||
},
|
||||
]
|
||||
|
||||
if (headState.open || isFlat) {
|
||||
const occurrences = filterSort(
|
||||
resolveOccurrences(proxyView, group),
|
||||
group.name,
|
||||
headState.filterText,
|
||||
headState.sortType,
|
||||
latencyTimeout!,
|
||||
{
|
||||
matchCase: headState.filterMatchCase,
|
||||
matchWholeWord: headState.filterMatchWholeWord,
|
||||
useRegularExpression: headState.filterUseRegularExpression,
|
||||
},
|
||||
)
|
||||
if (projected) {
|
||||
const occurrences = projected
|
||||
if (occurrences.length === 0) {
|
||||
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
|
||||
} else if (col > 1) {
|
||||
@@ -340,17 +407,30 @@ export const useRenderList = (
|
||||
}
|
||||
|
||||
cache.set(group.name, {
|
||||
now: group.now!,
|
||||
members: group.members,
|
||||
group,
|
||||
headState,
|
||||
col,
|
||||
latencyTimeout: latencyTimeout!,
|
||||
delays: groupDelays.get(group.name),
|
||||
occurrences: resolved,
|
||||
projected,
|
||||
items: ret,
|
||||
})
|
||||
return ret
|
||||
})
|
||||
|
||||
if (
|
||||
prevModeRef.current === mode &&
|
||||
prevGroupItemsRef.current.length === groupItems.length &&
|
||||
groupItems.every(
|
||||
(items, index) => items === prevGroupItemsRef.current[index],
|
||||
)
|
||||
) {
|
||||
return prevListRef.current
|
||||
}
|
||||
prevModeRef.current = mode
|
||||
prevGroupItemsRef.current = groupItems
|
||||
const retList = groupItems.flat()
|
||||
const filtered =
|
||||
mode === 'rule' ? retList.filter((item) => !item.group.hidden) : retList
|
||||
if (
|
||||
@@ -371,6 +451,7 @@ export const useRenderList = (
|
||||
mode,
|
||||
proxyView,
|
||||
renderGroups,
|
||||
repeatedNameGroups,
|
||||
selectedGroup,
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import delayManager from '@/services/delay'
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
|
||||
import { useGroupDelays, useGroupsDelays } from './use-group-delays'
|
||||
|
||||
vi.mock('react', () => ({
|
||||
useCallback: (callback: unknown) => callback,
|
||||
useSyncExternalStore: vi.fn((_subscribe, read) => read()),
|
||||
}))
|
||||
|
||||
it('keeps the closed-menu snapshot unchanged when another member settles', () => {
|
||||
const member: ResolvedProxyMember = {
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name: 'current', recordId: 'current' },
|
||||
node: {
|
||||
recordId: 'current',
|
||||
name: 'current',
|
||||
type: 'Shadowsocks',
|
||||
source: { kind: 'core', proxyName: 'current' },
|
||||
history: [],
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
},
|
||||
}
|
||||
vi.spyOn(delayManager, 'getDelayFix').mockReturnValue(42)
|
||||
const snapshot = vi.spyOn(delayManager, 'groupDelays')
|
||||
snapshot.mockReturnValue({ of: () => 42 })
|
||||
useGroupDelays('group', member, false)
|
||||
const [, read] = vi.mocked(useSyncExternalStore).mock.calls.at(-1)!
|
||||
let previous = read()
|
||||
let changes = 0
|
||||
for (let i = 0; i < 120; i++) {
|
||||
snapshot.mockReturnValue({ of: () => 42 })
|
||||
const next = read()
|
||||
if (!Object.is(next, previous)) changes++
|
||||
previous = next
|
||||
}
|
||||
expect(changes).toBe(0)
|
||||
})
|
||||
|
||||
it('subscribes once to the complete group name, including spaces', () => {
|
||||
vi.restoreAllMocks()
|
||||
const listen = vi.spyOn(delayManager, 'addGroupListener')
|
||||
const snapshot = useGroupsDelays(['Auto Select'])
|
||||
const [subscribe] = vi.mocked(useSyncExternalStore).mock.calls.at(-1)!
|
||||
const stop = subscribe(() => {})
|
||||
expect(
|
||||
listen.mock.calls.filter(([name]) => name === 'Auto Select'),
|
||||
).toHaveLength(1)
|
||||
expect([...snapshot.keys()]).toEqual(['Auto Select'])
|
||||
stop()
|
||||
listen.mockRestore()
|
||||
})
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
|
||||
import delayManager, { type DelaySnapshot } from '@/services/delay'
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
|
||||
const NO_DELAYS: DelaySnapshot = { of: () => -1 }
|
||||
|
||||
/** Exposes the external delay store to React, updating sort order only after a test settles. */
|
||||
export const useGroupDelays = (group: string | null): DelaySnapshot => {
|
||||
/** A closed menu observes only its current member, not every settled group delay. */
|
||||
export const useGroupDelays = (
|
||||
group: string | null,
|
||||
member: ResolvedProxyMember | undefined,
|
||||
menuOpen: boolean,
|
||||
): DelaySnapshot | number => {
|
||||
const subscribe = useCallback(
|
||||
(onSettle: () => void) =>
|
||||
group ? delayManager.addGroupListener(group, onSettle) : () => {},
|
||||
[group],
|
||||
)
|
||||
const read = useCallback(
|
||||
() => (group ? delayManager.groupDelays(group) : NO_DELAYS),
|
||||
[group],
|
||||
() =>
|
||||
group && menuOpen
|
||||
? delayManager.groupDelays(group)
|
||||
: group && member
|
||||
? delayManager.getDelayFix(member, group)
|
||||
: -1,
|
||||
[group, member, menuOpen],
|
||||
)
|
||||
|
||||
return useSyncExternalStore(subscribe, read, read)
|
||||
@@ -23,12 +31,12 @@ export const useGroupDelays = (group: string | null): DelaySnapshot => {
|
||||
export const useGroupsDelays = (
|
||||
groups: readonly string[],
|
||||
): ReadonlyMap<string, DelaySnapshot> => {
|
||||
// Subscribe by group membership; callers rebuild the array on every render.
|
||||
const groupKey = groups.join(' ')
|
||||
// Group membership must remain unambiguous when names contain spaces.
|
||||
const groupKey = JSON.stringify(groups)
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onSettle: () => void) => {
|
||||
const names = groupKey ? groupKey.split(' ') : []
|
||||
const names: string[] = JSON.parse(groupKey)
|
||||
const unsubscribes = names.map((name) =>
|
||||
delayManager.addGroupListener(name, onSettle),
|
||||
)
|
||||
|
||||
@@ -132,7 +132,7 @@ class DelayManager {
|
||||
const cached = this.groupSetSnapshots.get(groupKey)
|
||||
if (cached) return cached
|
||||
|
||||
const names = groupKey ? groupKey.split(' ') : []
|
||||
const names: string[] = JSON.parse(groupKey)
|
||||
const snapshots = new Map(
|
||||
names.map((name) => [name, this.groupDelays(name)]),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user