diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index d2dfb5825..66913f281 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -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(() => { diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index 84291bfe9..d4a8d1e00 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -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) => { diff --git a/src/services/delay.ts b/src/services/delay.ts index d27660ad4..48b5f6e39 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -34,7 +34,7 @@ class DelayManager { private cache = new Map() private urlMap = new Map() - private listenerMap = new Map void>() + private listenerMap = new Map void>>() private groupListenerMap = new Map 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. */