fix(proxies): retain independent delay listeners

Notify every mounted occurrence sharing a delay key and scope cleanup to its registered listener. Cover pending results, profile reset, and either occurrence unmounting without restoring direct unfenced updates.
This commit is contained in:
Tunglies
2026-11-10 05:55:28 +08:00
parent a2d875cebd
commit b02fb26c0c
3 changed files with 83 additions and 31 deletions
+1 -4
View File
@@ -48,10 +48,7 @@ export function useProxyDelayState(
useEffect(() => {
if (isPreset) return
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
}
return delayManager.addListener(name, groupName, setDelayState)
}, [name, groupName, isPreset])
const updateDelay = useCallback(() => {
+49 -2
View File
@@ -77,7 +77,7 @@ describe('group delay completion', () => {
describe('profile delay isolation', () => {
test('drops cached and queued results on switch, exposing current history', async () => {
const listener = vi.fn()
delayManager.setListener('a', 'g', listener)
const stop = delayManager.addListener('a', 'g', listener)
try {
delayManager.setDelay('a', 'g', 120)
const previousSnapshot = delayManager.groupDelays('g')
@@ -99,10 +99,57 @@ describe('profile delay isolation', () => {
expect(listener).toHaveBeenCalledOnce()
expect(settles).toBe(1)
} finally {
delayManager.removeListener('a', 'g')
stop()
}
})
test.each([0, 1])(
'keeps shared-key subscribers independent when occurrence %i unmounts',
async (removed) => {
const listeners = [vi.fn(), vi.fn()]
const stops = listeners.map((listener) =>
delayManager.addListener('a', 'g', listener),
)
try {
const checking = delayManager.checkDelay(node('a'), 'g', 5000)
await vi.advanceTimersByTimeAsync(1)
for (const listener of listeners) {
expect(listener).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ delay: -2 }),
)
}
await vi.runAllTimersAsync()
await checking
for (const listener of listeners) {
expect(listener.mock.calls.map(([update]) => update.delay)).toEqual([
-2, 120,
])
}
delayManager.setProfile('next')
for (const listener of listeners) {
expect(listener).toHaveBeenLastCalledWith({
delay: -1,
updatedAt: 0,
})
listener.mockClear()
}
const nextCheck = delayManager.checkDelay(node('a'), 'g', 5000)
stops[removed]()
await vi.runAllTimersAsync()
await nextCheck
expect(listeners[removed]).not.toHaveBeenCalled()
expect(
listeners[1 - removed].mock.calls.map(([update]) => update.delay),
).toEqual([-2, 120])
} finally {
for (const stop of stops) stop()
}
},
)
test.each(['success', 'failure'] as const)(
'ignores an old-profile single test completing with %s',
async (outcome) => {
+33 -25
View File
@@ -34,7 +34,7 @@ class DelayManager {
private cache = new Map<string, DelayUpdate>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
private listenerMap = new Map<string, Set<(update: DelayUpdate) => void>>()
private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles.
@@ -63,14 +63,16 @@ class DelayManager {
this.groupSetSnapshots.clear()
// Reset before a refreshed proxy snapshot can publish newer history.
for (const [key, listener] of this.listenerMap) {
try {
listener({ delay: -1, updatedAt: 0 })
} catch (error) {
console.error(
`[DelayManager] Failed to reset delay listener: ${key}`,
error,
)
for (const [key, listeners] of this.listenerMap) {
for (const listener of [...listeners]) {
try {
listener({ delay: -1, updatedAt: 0 })
} catch (error) {
console.error(
`[DelayManager] Failed to reset delay listener: ${key}`,
error,
)
}
}
}
for (const group of this.groupListenerMap.keys()) {
@@ -103,17 +105,19 @@ class DelayManager {
this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => {
const listener = this.listenerMap.get(key)
if (!listener) return
const listeners = this.listenerMap.get(key)
if (!listeners) return
queue.forEach((update) => {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
for (const listener of [...listeners]) {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
}
})
})
@@ -199,18 +203,22 @@ class DelayManager {
return url || 'http://cp.cloudflare.com/generate_204'
}
setListener(
addListener(
name: string,
group: string,
listener: (update: DelayUpdate) => void,
) {
): () => void {
const key = hashKey(name, group)
this.listenerMap.set(key, listener)
}
const listeners = this.listenerMap.get(key) ?? new Set()
listeners.add(listener)
this.listenerMap.set(key, listeners)
removeListener(name: string, group: string) {
const key = hashKey(name, group)
this.listenerMap.delete(key)
return () => {
const current = this.listenerMap.get(key)
if (!current) return
current.delete(listener)
if (current.size === 0) this.listenerMap.delete(key)
}
}
/** Multiple views may subscribe independently; notifications occur only after settle. */