mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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...'
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user