mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-11 13:02:22 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c576122a4c | ||
|
|
f86db4b344 | ||
|
|
389dddf512 | ||
|
|
304a65fc36 |
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v1.0.1": {
|
||||
"github/gh-aw-actions/setup@v1.2.2": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v1.0.1",
|
||||
"sha": "221ba4f4319de6b8e809d1a15528b3ae6a818aa5"
|
||||
"version": "v1.2.2",
|
||||
"sha": "53d0749e19424a918acd82b4d140264ce48f2f14"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
|
||||
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
|
||||
with:
|
||||
version: v1.0.1
|
||||
- name: Install Rust Stable
|
||||
|
||||
+282
-103
File diff suppressed because one or more lines are too long
@@ -33,6 +33,7 @@ max-daily-ai-credits: 5000
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
|
||||
pre-agent-steps:
|
||||
- name: Make Copilot quota exhaustion non-blocking
|
||||
|
||||
Generated
+288
-107
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@ permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
steps:
|
||||
- name: Load review rules from the base revision
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
+282
-103
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
max-ai-credits: 60
|
||||
max-turns: 12
|
||||
max-daily-ai-credits: 500
|
||||
|
||||
Generated
+4
-4
@@ -159,9 +159,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.4"
|
||||
version = "1.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
|
||||
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -10126,9 +10126,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.4.2"
|
||||
version = "9.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
|
||||
checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bzip2",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
||||
- 优化代理页面和首页节点卡片,减少延迟更新时的卡顿
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.12",
|
||||
"foxts": "^5.9.1",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next": "^27.0.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.31",
|
||||
@@ -79,7 +79,7 @@
|
||||
"react-dom": "19.3.1",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.1.13",
|
||||
"react-i18next": "18.0.1",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
Generated
+18
-21
@@ -239,8 +239,8 @@ importers:
|
||||
specifier: ^5.9.1
|
||||
version: 5.9.1
|
||||
i18next:
|
||||
specifier: ^26.4.2
|
||||
version: 26.4.2(typescript@6.0.3)
|
||||
specifier: ^27.0.0
|
||||
version: 27.0.1(typescript@6.0.3)
|
||||
js-yaml:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.2
|
||||
@@ -275,8 +275,8 @@ importers:
|
||||
specifier: ^7.88.0
|
||||
version: 7.88.0(react@19.3.1)
|
||||
react-i18next:
|
||||
specifier: 17.1.13
|
||||
version: 17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
|
||||
specifier: 18.0.1
|
||||
version: 18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3)
|
||||
react-markdown:
|
||||
specifier: 10.1.0
|
||||
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
|
||||
@@ -3689,8 +3689,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next@26.4.2:
|
||||
resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==}
|
||||
i18next@27.0.1:
|
||||
resolution: {integrity: sha512-KaE5ozP8UUeVpOJIwMVbbnpRKpyYQuxd9ZbseJoDzezE3nvLnZZQn20pkzyIJwPI2Uet/Qs3WzPTG812f6F/Eg==}
|
||||
engines: {node: '>=22'}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
@@ -4396,18 +4397,16 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-i18next@17.1.13:
|
||||
resolution: {integrity: sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==}
|
||||
react-i18next@18.0.1:
|
||||
resolution: {integrity: sha512-kPWXI0E/lrPj3+/umcKNzYZVnWmEedLiamMzDIHfbs+5ANf6yzvlZsjKou8f7kaGkfMMDLIZBejV+4aSN1CyBw==}
|
||||
engines: {node: '>=22'}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
'@types/react': '*'
|
||||
i18next: '>= 27.0.1'
|
||||
react: '>= 18.0.0'
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
'@types/react':
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
@@ -8533,7 +8532,7 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next@26.4.2(typescript@6.0.3):
|
||||
i18next@27.0.1(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
@@ -9357,15 +9356,13 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
|
||||
react-i18next@17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
|
||||
react-i18next@18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 4.0.1
|
||||
i18next: 26.4.2(typescript@6.0.3)
|
||||
i18next: 27.0.1(typescript@6.0.3)
|
||||
react: 19.3.1
|
||||
use-sync-external-store: 1.7.0(react@19.3.1)
|
||||
optionalDependencies:
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
'@types/react': 19.3.0
|
||||
typescript: 6.0.3
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
@@ -77,7 +77,7 @@ tauri-plugin-dialog = "2.8.4"
|
||||
tauri-plugin-fs = "2.7.1"
|
||||
tauri-plugin-deep-link = "2.6.3"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "9.4.2"
|
||||
zip = "9.5.0"
|
||||
flate2 = "1.1.11"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
@@ -97,7 +97,7 @@ async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.4"
|
||||
arc-swap = "1.9.5"
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.46", features = ["ring"] }
|
||||
webpki-roots = "1.0.11"
|
||||
|
||||
@@ -605,12 +605,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 +675,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 +768,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
|
||||
@@ -892,7 +900,7 @@ export const CurrentProxyCard = () => {
|
||||
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
isDirectMode || openSelect !== 'proxy'
|
||||
isDirectMode || openSelect !== 'proxy' || typeof delays === 'number'
|
||||
? []
|
||||
: sortProxyOptions(
|
||||
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) => {
|
||||
|
||||
@@ -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,119 @@
|
||||
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 })
|
||||
render()
|
||||
vi.mocked(filterSort).mockClear()
|
||||
vi.mocked(resolveMember).mockClear()
|
||||
state.delays.set('plain', { of: () => 43 })
|
||||
render()
|
||||
expect(filterSort).toHaveBeenCalledTimes(0)
|
||||
state.delays.set('sorted', { of: () => 43 })
|
||||
render()
|
||||
expect(filterSort).toHaveBeenCalledTimes(1)
|
||||
expect(resolveMember).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
@@ -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,14 @@ 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
|
||||
items: IRenderItem[]
|
||||
}
|
||||
|
||||
@@ -222,19 +222,51 @@ 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 renderList = useMemo<IRenderItem[]>(() => {
|
||||
const cache = (groupCacheRef.current ??= new Map())
|
||||
if (cachedViewRef.current !== proxyView) {
|
||||
cache.clear()
|
||||
cachedViewRef.current = proxyView
|
||||
}
|
||||
if (!proxyView) return []
|
||||
|
||||
if (isChainMode) {
|
||||
@@ -269,16 +301,13 @@ export const useRenderList = (
|
||||
}))
|
||||
}
|
||||
|
||||
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.now === group.now &&
|
||||
cached.members === group.members &&
|
||||
cached.group === group &&
|
||||
cached.headState === headState &&
|
||||
cached.col === col &&
|
||||
cached.latencyTimeout === latencyTimeout &&
|
||||
@@ -300,9 +329,18 @@ export const useRenderList = (
|
||||
},
|
||||
]
|
||||
|
||||
// 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(
|
||||
resolveOccurrences(proxyView, group),
|
||||
resolved,
|
||||
group.name,
|
||||
headState.filterText,
|
||||
headState.sortType,
|
||||
@@ -337,15 +375,17 @@ export const useRenderList = (
|
||||
})),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
resolved = undefined
|
||||
}
|
||||
|
||||
cache.set(group.name, {
|
||||
now: group.now!,
|
||||
members: group.members,
|
||||
group,
|
||||
headState,
|
||||
col,
|
||||
latencyTimeout: latencyTimeout!,
|
||||
delays: groupDelays.get(group.name),
|
||||
occurrences: resolved,
|
||||
items: ret,
|
||||
})
|
||||
return ret
|
||||
@@ -371,6 +411,7 @@ export const useRenderList = (
|
||||
mode,
|
||||
proxyView,
|
||||
renderGroups,
|
||||
repeatedNameGroups,
|
||||
selectedGroup,
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import delayManager from '@/services/delay'
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
|
||||
import { useGroupDelays } 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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user