diff --git a/Changelog.md b/Changelog.md index b2ba5b4fd..38a70f4eb 100644 --- a/Changelog.md +++ b/Changelog.md @@ -29,6 +29,7 @@ - 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组 - 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用 - 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」 +- 修复名称含空格的策略组在延迟检测后未更新排序和筛选 **🍎 macOS** diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 53bcfa732..27b9b5620 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -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 ( + { + if (!option.disabled) onChange(option.value) + }} + sx={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + pr: 1, + }} + > + {isFixed && ( + + 📌 + + )} + + {option.name} + + {!option.disabled && ( + + )} + + ) +}) + const PersistentProxySelect = ({ groupName, fixed, @@ -485,54 +549,15 @@ const PersistentProxySelect = ({ : delayManager.getDelayFix(option.member, groupName) return ( - { - if (!option.disabled) onChange(option.value) - }} - sx={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - pr: 1, - }} - > - {isFixed && ( - - 📌 - - )} - - {option.name} - - {!option.disabled && ( - - )} - + isFixed={isFixed} + fixedProxyInUsed={fixedProxyInUsed} + delay={delay} + onChange={onChange} + /> ) }) } @@ -898,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' || typeof delays === 'number' ? [] - : sortProxyOptions( - unsortedProxyOptions, - sortType, - delays.of, - defaultLatencyTimeout!, - ), + : sortType === 2 + ? nameSortedProxyOptions + : sortProxyOptions( + unsortedProxyOptions, + sortType, + delays.of, + defaultLatencyTimeout!, + ), [ delays, + nameSortedProxyOptions, isDirectMode, openSelect, unsortedProxyOptions, diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 8c308e75b..11e357387 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -571,6 +571,17 @@ function NormalProxyGroups(props: { mode: string }) { [renderList], ) + const onStickyHeadState = useCallback( + async (groupName: string, patch: Partial) => { + if (patch.filterText !== undefined) { + handleGroupLocationByName(groupName) + await stickyListRef.current?.waitForScrollEnd() + } + onHeadState(groupName, patch) + }, + [handleGroupLocationByName, onHeadState], + ) + const renderGroupItem = useCallback( (item: IRenderItem, _index: number, stickyed: boolean) => ( { - if (stickyed && patch.filterText !== undefined) { - handleGroupLocationByName(groupName) - await stickyListRef.current?.waitForScrollEnd() - } - onHeadState(groupName, patch) - }} + onHeadState={stickyed ? onStickyHeadState : onHeadState} onChangeProxy={handleChangeProxy} onGroupToggle={handleGroupToggle} /> @@ -595,7 +600,7 @@ function NormalProxyGroups(props: { mode: string }) { onHeadState, handleLocation, handleGroupToggle, - handleGroupLocationByName, + onStickyHeadState, ], ) diff --git a/src/components/proxy/proxy-render.test.ts b/src/components/proxy/proxy-render.test.ts new file mode 100644 index 000000000..ca3aa320e --- /dev/null +++ b/src/components/proxy/proxy-render.test.ts @@ -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) +}) diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index 5881295a3..6fb01d6b6 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -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 = ( + + {enable_group_icon && group.icon?.trim().startsWith('http') && ( + {group.name} + )} + {enable_group_icon && group.icon?.trim().startsWith('data') && ( + {group.name} + )} + {enable_group_icon && group.icon?.trim().startsWith(' + )} + {group.name}} + secondary={ + + + {group.type} + + {group.now} + + + + } + slotProps={{ + secondary: { + component: 'div', + sx: { + display: 'flex', + alignItems: 'center', + color: '#ccc', + }, + }, + }} + /> + + ) + + const proxyCount = ( + +
+ alpha(theme.palette.primary.main, 0.1), + color: (theme) => theme.palette.primary.main, + }} + /> +
+
+ ) + + const toolsBlock = ( + + {toolsOnLeft && proxyCount} + onLocation(group)} + onCheckDelay={() => onCheckAll(group.name)} + onHeadState={(p) => onHeadState(group.name, p)} + /> + {!toolsOnLeft && proxyCount} + + ) + + return ( +
+ { + if (headState?.open) { + onGroupToggle?.(group) + } + onHeadState?.(group.name, { open: !headState?.open }) + }} + > + + + {toolsOnLeft ? toolsBlock : nameBlock} + {toolsOnLeft ? nameBlock : toolsBlock} + {headState?.open ? : } + + + +
+ ) +} + +const ProxyEmptyRow = () => { + const { t } = useTranslation() + return ( + + + + {t('proxies.page.empty.noProxies')} + + + ) +} + +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) => ( - {enable_group_icon && group.icon?.trim().startsWith('http') && ( - {group.name} - )} - {enable_group_icon && group.icon?.trim().startsWith('data') && ( - {group.name} - )} - {enable_group_icon && group.icon?.trim().startsWith(' - )} - {group.name}} - secondary={ - - - {group.type} - - {group.now} - - - - } - slotProps={{ - secondary: { - component: 'div', - sx: { - display: 'flex', - alignItems: 'center', - color: '#ccc', - }, - }, - }} - /> - - ) - - const proxyCount = ( - -
- - alpha(theme.palette.primary.main, 0.1), - color: (theme) => theme.palette.primary.main, - }} - /> -
-
- ) - - const toolsBlock = ( - - {toolsOnLeft && proxyCount} - onLocation(group)} - onCheckDelay={() => onCheckAll(group.name)} - onHeadState={(p) => onHeadState(group.name, p)} - /> - {!toolsOnLeft && proxyCount} - - ) - - return ( -
- { - if (headState?.open) { - onGroupToggle?.(group) - } - onHeadState?.(group.name, { open: !headState?.open }) - }} - > - - - {toolsOnLeft ? toolsBlock : nameBlock} - {toolsOnLeft ? nameBlock : toolsBlock} - {headState?.open ? : } - - - -
- ) - } + if (type === 0) return if (type === 1) { return ( @@ -271,23 +291,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { } if (type === 3) { - return ( - - - - {t('proxies.page.empty.noProxies')} - - - ) + return } if (type === 4) { diff --git a/src/components/proxy/use-render-list.test.ts b/src/components/proxy/use-render-list.test.ts index fe3ac7e13..86e547014 100644 --- a/src/components/proxy/use-render-list.test.ts +++ b/src/components/proxy/use-render-list.test.ts @@ -106,14 +106,21 @@ it('skips delay-independent projections and reuses members for delay sorting', ( } state.delays.set('plain', { of: () => 42 }) state.delays.set('sorted', { of: () => 42 }) - render() + const initial = render().renderList vi.mocked(filterSort).mockClear() vi.mocked(resolveMember).mockClear() state.delays.set('plain', { of: () => 43 }) - render() + 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 }) - render() + 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() }) diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 24f1a182e..116f73972 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -68,6 +68,7 @@ type GroupCache = { /// does not throw away every other group's sorted order. delays: DelaySnapshot | undefined occurrences: ResolvedMemberOccurrence[] | undefined + projected: ResolvedMemberOccurrence[] | undefined items: IRenderItem[] } @@ -260,11 +261,15 @@ export const useRenderList = ( const groupCacheRef = useRef | null>(null) const cachedViewRef = useRef(proxyView) const prevListRef = useRef([]) + const prevGroupItemsRef = useRef([]) + const prevModeRef = useRef(mode) const renderList = useMemo(() => { const cache = (groupCacheRef.current ??= new Map()) if (cachedViewRef.current !== proxyView) { cache.clear() + prevGroupItemsRef.current = [] + prevListRef.current = [] cachedViewRef.current = proxyView } if (!proxyView) return [] @@ -301,7 +306,7 @@ export const useRenderList = ( })) } - 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) @@ -316,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 } @@ -328,29 +378,8 @@ export const useRenderList = ( testUrl: group.testUrl!, }, ] - - // 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 - if (headState.open || isFlat) { - resolved ??= resolveOccurrences(proxyView, group) - const occurrences = filterSort( - resolved, - 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) { @@ -375,8 +404,6 @@ export const useRenderList = ( })), ) } - } else { - resolved = undefined } cache.set(group.name, { @@ -386,11 +413,24 @@ export const useRenderList = ( 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 ( diff --git a/src/hooks/use-group-delays.test.ts b/src/hooks/use-group-delays.test.ts index ada35bc84..f7d7b2d67 100644 --- a/src/hooks/use-group-delays.test.ts +++ b/src/hooks/use-group-delays.test.ts @@ -4,7 +4,7 @@ import { expect, it, vi } from 'vitest' import delayManager from '@/services/delay' import type { ResolvedProxyMember } from '@/types/proxy-view' -import { useGroupDelays } from './use-group-delays' +import { useGroupDelays, useGroupsDelays } from './use-group-delays' vi.mock('react', () => ({ useCallback: (callback: unknown) => callback, @@ -43,3 +43,17 @@ it('keeps the closed-menu snapshot unchanged when another member settles', () => } 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() +}) diff --git a/src/hooks/use-group-delays.ts b/src/hooks/use-group-delays.ts index 6a051923a..d2ef763e4 100644 --- a/src/hooks/use-group-delays.ts +++ b/src/hooks/use-group-delays.ts @@ -31,12 +31,12 @@ export const useGroupDelays = ( export const useGroupsDelays = ( groups: readonly string[], ): ReadonlyMap => { - // 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), ) diff --git a/src/services/delay.ts b/src/services/delay.ts index 5758d41e8..565d0fd7b 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -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)]), )