Skip to content

Commit 9e9f2b9

Browse files
authored
fix(realtime): debounce the reconnecting toast to stop transient-blip flashes (#5111)
* fix(realtime): debounce the reconnecting toast to stop transient-blip flashes The "Reconnecting..." persistent toast fired the instant isReconnecting flipped true, so sub-second transport blips that self-heal on the first retry flashed a scary alert. Add useStableFlag, an anti-flicker boolean that delays the rising edge (2s, so brief blips never surface) and holds the falling edge (1.5s min visible, so a drop just past the delay does not flash-and-vanish). The socket flag stays accurate; only the user-facing alarm is smoothed. State machine extracted into a framework-agnostic controller with unit coverage for both flicker modes. * fix(realtime): reset stable-flag React state on options change; de-vacuous blip test Address Greptile review: - useStableFlag: reset React state to the fresh controller's baseline when the controller is recreated on an options change, so a dynamic consumer changing delayMs/minVisibleMs while active with value already false can no longer strand the flag at true. - test: read the live probe.active getter in the blip test instead of a destructured snapshot, which was bound to false at destructure time and made the assertion vacuous.
1 parent 05cd7d9 commit 9e9f2b9

3 files changed

Lines changed: 309 additions & 1 deletion

File tree

apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,23 @@ import {
1212
type WorkspacePermissions,
1313
workspaceKeys,
1414
} from '@/hooks/queries/workspace'
15+
import { useStableFlag } from '@/hooks/use-stable-flag'
1516
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
1617
import { useOperationQueueStore } from '@/stores/operation-queue/store'
1718

1819
const logger = createLogger('WorkspacePermissionsProvider')
1920

21+
/**
22+
* Anti-flicker timing for the "Reconnecting..." toast. Socket.IO flips
23+
* `isReconnecting` on any disconnect — including sub-second transport hiccups
24+
* that recover on the first retry — so we delay surfacing the toast until the
25+
* drop has lasted long enough to matter, then hold it on screen long enough to
26+
* read. Together these suppress both flicker modes (flash-on and flash-off)
27+
* while still alerting on real outages.
28+
*/
29+
const RECONNECTING_TOAST_DELAY_MS = 2000
30+
const RECONNECTING_TOAST_MIN_VISIBLE_MS = 1500
31+
2032
interface PersistentToastOptions {
2133
description?: string
2234
action?: { label: string; onClick: () => void }
@@ -115,9 +127,13 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
115127

116128
const isOfflineMode = hasOperationError
117129
const isJoinBlocked = Boolean(blockedJoinWorkflowId) && blockedJoinWorkflowId === urlWorkflowId
130+
const showReconnecting = useStableFlag(isReconnecting, {
131+
delayMs: RECONNECTING_TOAST_DELAY_MS,
132+
minVisibleMs: RECONNECTING_TOAST_MIN_VISIBLE_MS,
133+
})
118134
const realtimeStatusMessage = isOfflineMode
119135
? null
120-
: isReconnecting
136+
: showReconnecting
121137
? 'Reconnecting...'
122138
: isRetryingWorkflowJoin
123139
? 'Joining workflow...'
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Tests for the `createStableFlagController` state machine behind `useStableFlag`.
5+
* The controller is framework-agnostic so the anti-flicker timing can be driven
6+
* with fake timers and no DOM; the thin React wrapper is covered by manual QA.
7+
*/
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { createStableFlagController } from '@/hooks/use-stable-flag'
10+
11+
const DELAY_MS = 2000
12+
const MIN_VISIBLE_MS = 1500
13+
14+
function setup(options = { delayMs: DELAY_MS, minVisibleMs: MIN_VISIBLE_MS }) {
15+
const states: boolean[] = []
16+
let active = false
17+
const controller = createStableFlagController((next) => {
18+
active = next
19+
states.push(next)
20+
}, options)
21+
return {
22+
controller,
23+
states,
24+
get active() {
25+
return active
26+
},
27+
}
28+
}
29+
30+
describe('createStableFlagController', () => {
31+
beforeEach(() => {
32+
vi.useFakeTimers()
33+
})
34+
35+
afterEach(() => {
36+
vi.clearAllTimers()
37+
vi.useRealTimers()
38+
})
39+
40+
it('suppresses a blip that heals before the delay (flash-on)', () => {
41+
const probe = setup()
42+
43+
probe.controller.setValue(true)
44+
vi.advanceTimersByTime(DELAY_MS - 1)
45+
probe.controller.setValue(false)
46+
vi.advanceTimersByTime(10_000)
47+
48+
expect(probe.states).toEqual([])
49+
expect(probe.active).toBe(false)
50+
})
51+
52+
it('does not turn on one tick before the delay boundary', () => {
53+
const probe = setup()
54+
55+
probe.controller.setValue(true)
56+
vi.advanceTimersByTime(DELAY_MS - 1)
57+
58+
expect(probe.active).toBe(false)
59+
expect(probe.states).toEqual([])
60+
})
61+
62+
it('turns on exactly at the delay boundary', () => {
63+
const probe = setup()
64+
65+
probe.controller.setValue(true)
66+
vi.advanceTimersByTime(DELAY_MS)
67+
68+
expect(probe.states).toEqual([true])
69+
expect(probe.active).toBe(true)
70+
})
71+
72+
it('holds the flag on for the minimum-visible window (flash-off)', () => {
73+
const probe = setup()
74+
75+
probe.controller.setValue(true)
76+
vi.advanceTimersByTime(DELAY_MS) // shown
77+
expect(probe.active).toBe(true)
78+
79+
// Value clears almost immediately after showing.
80+
vi.advanceTimersByTime(100)
81+
probe.controller.setValue(false)
82+
expect(probe.active).toBe(true) // still held
83+
84+
vi.advanceTimersByTime(MIN_VISIBLE_MS - 100 - 1)
85+
expect(probe.active).toBe(true)
86+
87+
vi.advanceTimersByTime(1)
88+
expect(probe.active).toBe(false)
89+
expect(probe.states).toEqual([true, false])
90+
})
91+
92+
it('clears immediately when the value has already been visible past the minimum', () => {
93+
const probe = setup()
94+
95+
probe.controller.setValue(true)
96+
vi.advanceTimersByTime(DELAY_MS)
97+
vi.advanceTimersByTime(MIN_VISIBLE_MS + 500) // well past the floor
98+
probe.controller.setValue(false)
99+
100+
expect(probe.active).toBe(false)
101+
expect(probe.states).toEqual([true, false])
102+
})
103+
104+
it('keeps the flag on through a flap while held, without re-delaying', () => {
105+
const probe = setup()
106+
107+
probe.controller.setValue(true)
108+
vi.advanceTimersByTime(DELAY_MS) // shown
109+
probe.controller.setValue(false) // schedules hide
110+
vi.advanceTimersByTime(500)
111+
probe.controller.setValue(true) // reconnect flaps back before hide fires
112+
113+
vi.advanceTimersByTime(10_000)
114+
expect(probe.active).toBe(true)
115+
expect(probe.states).toEqual([true])
116+
})
117+
118+
it('is idempotent on repeated setValue(true) — schedules a single show', () => {
119+
const probe = setup()
120+
121+
probe.controller.setValue(true)
122+
vi.advanceTimersByTime(500)
123+
probe.controller.setValue(true)
124+
probe.controller.setValue(true)
125+
126+
vi.advanceTimersByTime(DELAY_MS - 500)
127+
expect(probe.states).toEqual([true]) // exactly one transition, fired at the original deadline
128+
})
129+
130+
it('dispose cancels a pending show', () => {
131+
const probe = setup()
132+
133+
probe.controller.setValue(true)
134+
probe.controller.dispose()
135+
vi.advanceTimersByTime(10_000)
136+
137+
expect(probe.states).toEqual([])
138+
})
139+
140+
it('dispose cancels a pending hide', () => {
141+
const probe = setup()
142+
143+
probe.controller.setValue(true)
144+
vi.advanceTimersByTime(DELAY_MS)
145+
probe.controller.setValue(false) // schedules hide within min-visible window
146+
probe.controller.dispose()
147+
vi.advanceTimersByTime(10_000)
148+
149+
expect(probe.states).toEqual([true]) // hide never fired
150+
})
151+
152+
it('with zero options, mirrors the value on the next tick', () => {
153+
const probe = setup({ delayMs: 0, minVisibleMs: 0 })
154+
155+
probe.controller.setValue(true)
156+
vi.advanceTimersByTime(0)
157+
expect(probe.active).toBe(true)
158+
159+
probe.controller.setValue(false)
160+
expect(probe.active).toBe(false)
161+
expect(probe.states).toEqual([true, false])
162+
})
163+
})

apps/sim/hooks/use-stable-flag.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
export interface StableFlagOptions {
4+
/**
5+
* Time `value` must stay continuously true before the flag turns on. Suppresses
6+
* brief flashes for blips that heal within the window. Defaults to `0` (no delay).
7+
*/
8+
delayMs?: number
9+
/**
10+
* Minimum time the flag stays on once shown, even if `value` clears immediately
11+
* after. Prevents a flash-and-vanish when `value` is true just past `delayMs`.
12+
* Defaults to `0` (clears as soon as `value` does).
13+
*/
14+
minVisibleMs?: number
15+
}
16+
17+
/**
18+
* Framework-agnostic state machine behind {@link useStableFlag}. Extracted so the
19+
* anti-flicker timing can be unit-tested with fake timers without a DOM. Relies on
20+
* the ambient `setTimeout`/`clearTimeout`/`Date.now`, which fake timers replace.
21+
*
22+
* `onChange` fires whenever the smoothed flag flips. `setValue` is idempotent — it
23+
* is safe to feed it the same value repeatedly (e.g. from React effect re-runs).
24+
*/
25+
export function createStableFlagController(
26+
onChange: (active: boolean) => void,
27+
{ delayMs = 0, minVisibleMs = 0 }: StableFlagOptions = {}
28+
) {
29+
let active = false
30+
let shownAt: number | null = null
31+
let showTimer: ReturnType<typeof setTimeout> | null = null
32+
let hideTimer: ReturnType<typeof setTimeout> | null = null
33+
34+
const clearShow = () => {
35+
if (showTimer !== null) {
36+
clearTimeout(showTimer)
37+
showTimer = null
38+
}
39+
}
40+
const clearHide = () => {
41+
if (hideTimer !== null) {
42+
clearTimeout(hideTimer)
43+
hideTimer = null
44+
}
45+
}
46+
47+
const show = () => {
48+
showTimer = null
49+
shownAt = Date.now()
50+
active = true
51+
onChange(true)
52+
}
53+
const hide = () => {
54+
hideTimer = null
55+
shownAt = null
56+
active = false
57+
onChange(false)
58+
}
59+
60+
return {
61+
setValue(value: boolean) {
62+
if (value) {
63+
clearHide()
64+
if (active || showTimer !== null) {
65+
return
66+
}
67+
showTimer = setTimeout(show, delayMs)
68+
return
69+
}
70+
71+
clearShow()
72+
if (!active || hideTimer !== null) {
73+
return
74+
}
75+
76+
const elapsed = shownAt === null ? minVisibleMs : Date.now() - shownAt
77+
const remaining = minVisibleMs - elapsed
78+
if (remaining <= 0) {
79+
hide()
80+
return
81+
}
82+
hideTimer = setTimeout(hide, remaining)
83+
},
84+
dispose() {
85+
clearShow()
86+
clearHide()
87+
},
88+
}
89+
}
90+
91+
/**
92+
* Anti-flicker boolean. Mirrors `value` but smooths both edges so transient
93+
* toggles never produce a visible flash:
94+
*
95+
* - Rising edge — `value` must hold true for `delayMs` before the flag turns on.
96+
* - Falling edge — once on, the flag stays on for at least `minVisibleMs`.
97+
*
98+
* With both options at `0` it returns `value` unchanged (after a tick). Useful for
99+
* connection/loading indicators that would otherwise flicker on sub-second changes.
100+
*/
101+
export function useStableFlag(value: boolean, options: StableFlagOptions = {}): boolean {
102+
const [active, setActive] = useState(false)
103+
const { delayMs = 0, minVisibleMs = 0 } = options
104+
const valueRef = useRef(value)
105+
valueRef.current = value
106+
const controllerRef = useRef<ReturnType<typeof createStableFlagController> | null>(null)
107+
108+
useEffect(() => {
109+
// Reset to the fresh controller's baseline. Without this, recreating the
110+
// controller on an options change while `active` is true and `value` is
111+
// already false would strand the React state at true — the new controller
112+
// starts internally false, so its `setValue(false)` early-returns and never
113+
// emits `onChange(false)`.
114+
setActive(false)
115+
const controller = createStableFlagController(setActive, { delayMs, minVisibleMs })
116+
controllerRef.current = controller
117+
controller.setValue(valueRef.current)
118+
return () => {
119+
controller.dispose()
120+
controllerRef.current = null
121+
}
122+
}, [delayMs, minVisibleMs])
123+
124+
useEffect(() => {
125+
controllerRef.current?.setValue(value)
126+
}, [value])
127+
128+
return active
129+
}

0 commit comments

Comments
 (0)