Compare commits

..
Author SHA1 Message Date
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b8c13c0370 chore(deps): update dependency github/gh-aw to v1.2.2 (#8620)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 04:02:03 +00:00
12 changed files with 296 additions and 697 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
with:
version: v1.0.1
version: v1.2.2
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
with:
-2
View File
@@ -29,7 +29,6 @@
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
- 修复名称含空格的策略组在延迟检测后未更新排序和筛选
**🍎 macOS**
@@ -49,7 +48,6 @@
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
- 优化代理页面和首页节点卡片,减少延迟更新时的卡顿
**🍎 macOS**
+64 -107
View File
@@ -31,7 +31,6 @@ import {
} from '@mui/material'
import { useLockFn } from 'ahooks'
import {
memo,
useCallback,
useEffect,
useEffectEvent,
@@ -205,11 +204,14 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
const sortProxyOptions = (
options: ProxyOption[],
sortType: 0 | 1,
sortType: ProxySortType,
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
@@ -431,72 +433,6 @@ 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,
@@ -549,15 +485,54 @@ const PersistentProxySelect = ({
: delayManager.getDelayFix(option.member, groupName)
return (
<ProxyMenuOption
<MenuItem
key={option.value}
option={option}
role="option"
aria-selected={selected}
selected={selected}
isFixed={isFixed}
fixedProxyInUsed={fixedProxyInUsed}
delay={delay}
onChange={onChange}
/>
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>
)
})
}
@@ -630,14 +605,12 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
const onSelectionError = useCallback(
(error: unknown) => {
const { select } = useProxySelection({
onError: (error) => {
console.error('代理切换失败', error)
refreshProxy()
},
[refreshProxy],
)
const { select } = useProxySelection({ onError: onSelectionError })
})
const mode = clashConfig?.mode?.toLowerCase() || 'rule'
const isGlobalMode = mode === 'global'
@@ -700,6 +673,7 @@ export const CurrentProxyCard = () => {
if (proxyView && lastGroupName !== selectedGroupName) {
setLastGroupName(selectedGroupName)
}
const delays = useGroupDelays(selectedGroupName || null)
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
if (!proxyView || isDirectMode) return null
@@ -793,17 +767,10 @@ export const CurrentProxyCard = () => {
const currentProxy = currentMember ? memberDetails(currentMember) : undefined
const selectedProxyName = currentMember?.ref.name ?? ''
const delays = useGroupDelays(
selectedGroupName,
currentMember,
openSelect === 'proxy',
)
const currentDelay =
typeof delays === 'number'
? delays
: currentMember
? delays.of(currentMember)
: -1
currentMember && selectedGroupName
? delayManager.getDelayFix(currentMember, selectedGroupName)
: -1
const signalInfo =
currentProxy && selectedGroupName
@@ -923,28 +890,18 @@ 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' || typeof delays === 'number'
isDirectMode || openSelect !== 'proxy'
? []
: sortType === 2
? nameSortedProxyOptions
: sortProxyOptions(
unsortedProxyOptions,
sortType,
delays.of,
defaultLatencyTimeout!,
),
: sortProxyOptions(
unsortedProxyOptions,
sortType,
delays.of,
defaultLatencyTimeout!,
),
[
delays,
nameSortedProxyOptions,
isDirectMode,
openSelect,
unsortedProxyOptions,
+11 -18
View File
@@ -469,14 +469,12 @@ function NormalProxyGroups(props: { mode: string }) {
}
}, [handleScroll])
const onSelectionError = useCallback(
(error: unknown) => {
const { select } = useProxySelection({
onError: (error) => {
console.error('代理切换失败', error)
onProxies()
},
[onProxies],
)
const { select } = useProxySelection({ onError: onSelectionError })
})
const handleChangeProxy = useCallback(
(group: ProxyGroupView, member: ResolvedProxyMember) => {
@@ -571,17 +569,6 @@ 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
@@ -589,7 +576,13 @@ function NormalProxyGroups(props: { mode: string }) {
stickyed={stickyed}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={stickyed ? onStickyHeadState : onHeadState}
onHeadState={async (groupName, patch) => {
if (stickyed && patch.filterText !== undefined) {
handleGroupLocationByName(groupName)
await stickyListRef.current?.waitForScrollEnd()
}
onHeadState(groupName, patch)
}}
onChangeProxy={handleChangeProxy}
onGroupToggle={handleGroupToggle}
/>
@@ -600,7 +593,7 @@ function NormalProxyGroups(props: { mode: string }) {
onHeadState,
handleLocation,
handleGroupToggle,
onStickyHeadState,
handleGroupLocationByName,
],
)
-68
View File
@@ -1,68 +0,0 @@
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)
})
+179 -183
View File
@@ -43,7 +43,7 @@ interface RenderProps {
onGroupToggle?: (group: IRenderItem['group']) => void
}
const ProxyGroupHeader = (props: RenderProps) => {
export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
const { t } = useTranslation()
const {
item,
@@ -51,9 +51,11 @@ const ProxyGroupHeader = (props: RenderProps) => {
onLocation,
onCheckAll,
onHeadState,
onChangeProxy,
onGroupToggle,
isChainMode: _ = false,
} = props
const { group, headState } = item
const { type, group, headState, member, memberCol } = item
const { verge } = useVerge()
const enable_group_icon = verge?.enable_group_icon ?? true
const toolsOnLeft = verge?.proxy_group_tools_position === 'left'
@@ -67,187 +69,11 @@ const ProxyGroupHeader = (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
@@ -261,7 +87,161 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
))
}, [type, memberCol, item.key, group, showType, onChangeProxy])
if (type === 0) return <ProxyGroupHeader {...props} />
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 === 1) {
return (
@@ -291,7 +271,23 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
}
if (type === 3) {
return <ProxyEmptyRow />
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>
)
}
if (type === 4) {
-3
View File
@@ -29,9 +29,6 @@ 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,
@@ -1,126 +0,0 @@
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()
})
+31 -112
View File
@@ -17,7 +17,7 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { filterSort, isDelayFilter } from './use-filter-sort'
import { filterSort } from './use-filter-sort'
import {
DEFAULT_STATE,
useHeadStateNew,
@@ -60,15 +60,14 @@ export const hasRenderableItems = (
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
type GroupCache = {
group: ProxyGroupView
now: string | undefined
members: ProxyGroupView['members']
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[]
}
@@ -223,55 +222,19 @@ export const useRenderList = (
return current?.kind === 'group' ? [global, current.group] : [global]
}, [mode, proxyView])
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.
// Every group this list draws, so a test settling in any of them re-sorts that group.
const renderedGroupNames = useMemo(() => {
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])
if (!proxyView) return []
if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return renderGroups.map(({ name }) => name)
}, [isChainMode, proxyView, renderGroups, selectedGroup])
const groupDelays = useGroupsDelays(renderedGroupNames)
const groupCacheRef = useRef<Map<string, GroupCache> | null>(null)
const cachedViewRef = useRef(proxyView)
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
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) {
@@ -306,13 +269,16 @@ export const useRenderList = (
}))
}
const groupItems = renderGroups.map((group) => {
const cache = groupCacheRef.current
const retList = renderGroups.flatMap((group) => {
const isFlat = group === proxyView.global
const headState = headStates[group.name] || DEFAULT_STATE
const cached = cache.get(group.name)
if (
cached &&
cached.group === group &&
cached.now === group.now &&
cached.members === group.members &&
cached.headState === headState &&
cached.col === col &&
cached.latencyTimeout === latencyTimeout &&
@@ -321,51 +287,6 @@ 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 }
@@ -378,8 +299,20 @@ export const useRenderList = (
testUrl: group.testUrl!,
},
]
if (projected) {
const occurrences = projected
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 (occurrences.length === 0) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
} else if (col > 1) {
@@ -407,30 +340,17 @@ export const useRenderList = (
}
cache.set(group.name, {
group,
now: group.now!,
members: group.members,
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 (
@@ -451,7 +371,6 @@ export const useRenderList = (
mode,
proxyView,
renderGroups,
repeatedNameGroups,
selectedGroup,
])
-59
View File
@@ -1,59 +0,0 @@
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()
})
+9 -17
View File
@@ -1,27 +1,19 @@
import { useCallback, useSyncExternalStore } from 'react'
import delayManager, { type DelaySnapshot } from '@/services/delay'
import type { ResolvedProxyMember } from '@/types/proxy-view'
/** 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 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 => {
const subscribe = useCallback(
(onSettle: () => void) =>
group ? delayManager.addGroupListener(group, onSettle) : () => {},
[group],
)
const read = useCallback(
() =>
group && menuOpen
? delayManager.groupDelays(group)
: group && member
? delayManager.getDelayFix(member, group)
: -1,
[group, member, menuOpen],
() => (group ? delayManager.groupDelays(group) : NO_DELAYS),
[group],
)
return useSyncExternalStore(subscribe, read, read)
@@ -31,12 +23,12 @@ export const useGroupDelays = (
export const useGroupsDelays = (
groups: readonly string[],
): ReadonlyMap<string, DelaySnapshot> => {
// Group membership must remain unambiguous when names contain spaces.
const groupKey = JSON.stringify(groups)
// Subscribe by group membership; callers rebuild the array on every render.
const groupKey = groups.join(' ')
const subscribe = useCallback(
(onSettle: () => void) => {
const names: string[] = JSON.parse(groupKey)
const names = groupKey ? groupKey.split(' ') : []
const unsubscribes = names.map((name) =>
delayManager.addGroupListener(name, onSettle),
)
+1 -1
View File
@@ -132,7 +132,7 @@ class DelayManager {
const cached = this.groupSetSnapshots.get(groupKey)
if (cached) return cached
const names: string[] = JSON.parse(groupKey)
const names = groupKey ? groupKey.split(' ') : []
const snapshots = new Map(
names.map((name) => [name, this.groupDelays(name)]),
)