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.
This commit is contained in:
Waleed
2026-06-17 11:12:41 -07:00
committed by GitHub
parent 05cd7d92a8
commit 9e9f2b9e1f
3 changed files with 309 additions and 1 deletions
@@ -12,11 +12,23 @@ import {
type WorkspacePermissions,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useStableFlag } from '@/hooks/use-stable-flag'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
/**
* Anti-flicker timing for the "Reconnecting..." toast. Socket.IO flips
* `isReconnecting` on any disconnect — including sub-second transport hiccups
* that recover on the first retry — so we delay surfacing the toast until the
* drop has lasted long enough to matter, then hold it on screen long enough to
* read. Together these suppress both flicker modes (flash-on and flash-off)
* while still alerting on real outages.
*/
const RECONNECTING_TOAST_DELAY_MS = 2000
const RECONNECTING_TOAST_MIN_VISIBLE_MS = 1500
interface PersistentToastOptions {
description?: string
action?: { label: string; onClick: () => void }
@@ -115,9 +127,13 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
const isOfflineMode = hasOperationError
const isJoinBlocked = Boolean(blockedJoinWorkflowId) && blockedJoinWorkflowId === urlWorkflowId
const showReconnecting = useStableFlag(isReconnecting, {
delayMs: RECONNECTING_TOAST_DELAY_MS,
minVisibleMs: RECONNECTING_TOAST_MIN_VISIBLE_MS,
})
const realtimeStatusMessage = isOfflineMode
? null
: isReconnecting
: showReconnecting
? 'Reconnecting...'
: isRetryingWorkflowJoin
? 'Joining workflow...'
+163
View File
@@ -0,0 +1,163 @@
/**
* @vitest-environment node
*
* Tests for the `createStableFlagController` state machine behind `useStableFlag`.
* The controller is framework-agnostic so the anti-flicker timing can be driven
* with fake timers and no DOM; the thin React wrapper is covered by manual QA.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createStableFlagController } from '@/hooks/use-stable-flag'
const DELAY_MS = 2000
const MIN_VISIBLE_MS = 1500
function setup(options = { delayMs: DELAY_MS, minVisibleMs: MIN_VISIBLE_MS }) {
const states: boolean[] = []
let active = false
const controller = createStableFlagController((next) => {
active = next
states.push(next)
}, options)
return {
controller,
states,
get active() {
return active
},
}
}
describe('createStableFlagController', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('suppresses a blip that heals before the delay (flash-on)', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS - 1)
probe.controller.setValue(false)
vi.advanceTimersByTime(10_000)
expect(probe.states).toEqual([])
expect(probe.active).toBe(false)
})
it('does not turn on one tick before the delay boundary', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS - 1)
expect(probe.active).toBe(false)
expect(probe.states).toEqual([])
})
it('turns on exactly at the delay boundary', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS)
expect(probe.states).toEqual([true])
expect(probe.active).toBe(true)
})
it('holds the flag on for the minimum-visible window (flash-off)', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS) // shown
expect(probe.active).toBe(true)
// Value clears almost immediately after showing.
vi.advanceTimersByTime(100)
probe.controller.setValue(false)
expect(probe.active).toBe(true) // still held
vi.advanceTimersByTime(MIN_VISIBLE_MS - 100 - 1)
expect(probe.active).toBe(true)
vi.advanceTimersByTime(1)
expect(probe.active).toBe(false)
expect(probe.states).toEqual([true, false])
})
it('clears immediately when the value has already been visible past the minimum', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS)
vi.advanceTimersByTime(MIN_VISIBLE_MS + 500) // well past the floor
probe.controller.setValue(false)
expect(probe.active).toBe(false)
expect(probe.states).toEqual([true, false])
})
it('keeps the flag on through a flap while held, without re-delaying', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS) // shown
probe.controller.setValue(false) // schedules hide
vi.advanceTimersByTime(500)
probe.controller.setValue(true) // reconnect flaps back before hide fires
vi.advanceTimersByTime(10_000)
expect(probe.active).toBe(true)
expect(probe.states).toEqual([true])
})
it('is idempotent on repeated setValue(true) — schedules a single show', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(500)
probe.controller.setValue(true)
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS - 500)
expect(probe.states).toEqual([true]) // exactly one transition, fired at the original deadline
})
it('dispose cancels a pending show', () => {
const probe = setup()
probe.controller.setValue(true)
probe.controller.dispose()
vi.advanceTimersByTime(10_000)
expect(probe.states).toEqual([])
})
it('dispose cancels a pending hide', () => {
const probe = setup()
probe.controller.setValue(true)
vi.advanceTimersByTime(DELAY_MS)
probe.controller.setValue(false) // schedules hide within min-visible window
probe.controller.dispose()
vi.advanceTimersByTime(10_000)
expect(probe.states).toEqual([true]) // hide never fired
})
it('with zero options, mirrors the value on the next tick', () => {
const probe = setup({ delayMs: 0, minVisibleMs: 0 })
probe.controller.setValue(true)
vi.advanceTimersByTime(0)
expect(probe.active).toBe(true)
probe.controller.setValue(false)
expect(probe.active).toBe(false)
expect(probe.states).toEqual([true, false])
})
})
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react'
export interface StableFlagOptions {
/**
* Time `value` must stay continuously true before the flag turns on. Suppresses
* brief flashes for blips that heal within the window. Defaults to `0` (no delay).
*/
delayMs?: number
/**
* Minimum time the flag stays on once shown, even if `value` clears immediately
* after. Prevents a flash-and-vanish when `value` is true just past `delayMs`.
* Defaults to `0` (clears as soon as `value` does).
*/
minVisibleMs?: number
}
/**
* Framework-agnostic state machine behind {@link useStableFlag}. Extracted so the
* anti-flicker timing can be unit-tested with fake timers without a DOM. Relies on
* the ambient `setTimeout`/`clearTimeout`/`Date.now`, which fake timers replace.
*
* `onChange` fires whenever the smoothed flag flips. `setValue` is idempotent — it
* is safe to feed it the same value repeatedly (e.g. from React effect re-runs).
*/
export function createStableFlagController(
onChange: (active: boolean) => void,
{ delayMs = 0, minVisibleMs = 0 }: StableFlagOptions = {}
) {
let active = false
let shownAt: number | null = null
let showTimer: ReturnType<typeof setTimeout> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
const clearShow = () => {
if (showTimer !== null) {
clearTimeout(showTimer)
showTimer = null
}
}
const clearHide = () => {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
}
const show = () => {
showTimer = null
shownAt = Date.now()
active = true
onChange(true)
}
const hide = () => {
hideTimer = null
shownAt = null
active = false
onChange(false)
}
return {
setValue(value: boolean) {
if (value) {
clearHide()
if (active || showTimer !== null) {
return
}
showTimer = setTimeout(show, delayMs)
return
}
clearShow()
if (!active || hideTimer !== null) {
return
}
const elapsed = shownAt === null ? minVisibleMs : Date.now() - shownAt
const remaining = minVisibleMs - elapsed
if (remaining <= 0) {
hide()
return
}
hideTimer = setTimeout(hide, remaining)
},
dispose() {
clearShow()
clearHide()
},
}
}
/**
* Anti-flicker boolean. Mirrors `value` but smooths both edges so transient
* toggles never produce a visible flash:
*
* - Rising edge — `value` must hold true for `delayMs` before the flag turns on.
* - Falling edge — once on, the flag stays on for at least `minVisibleMs`.
*
* With both options at `0` it returns `value` unchanged (after a tick). Useful for
* connection/loading indicators that would otherwise flicker on sub-second changes.
*/
export function useStableFlag(value: boolean, options: StableFlagOptions = {}): boolean {
const [active, setActive] = useState(false)
const { delayMs = 0, minVisibleMs = 0 } = options
const valueRef = useRef(value)
valueRef.current = value
const controllerRef = useRef<ReturnType<typeof createStableFlagController> | null>(null)
useEffect(() => {
// Reset to the fresh controller's baseline. Without this, recreating the
// controller on an options change while `active` is true and `value` is
// already false would strand the React state at true — the new controller
// starts internally false, so its `setValue(false)` early-returns and never
// emits `onChange(false)`.
setActive(false)
const controller = createStableFlagController(setActive, { delayMs, minVisibleMs })
controllerRef.current = controller
controller.setValue(valueRef.current)
return () => {
controller.dispose()
controllerRef.current = null
}
}, [delayMs, minVisibleMs])
useEffect(() => {
controllerRef.current?.setValue(value)
}, [value])
return active
}