Add hysteresis and EMA to ping to avoid flickering network badge (#7197)

* Don't use WEAK and yellow

* fmt && lint && tsc

* Fix up the rebase & dark mode colors

* Update src/hooks/useNetworkStatus.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Change Weak to Ok

* Change Connected to Strong

* fmt

* Sync selectors for start sketch

* Remove unused test-util brought back in a rebase

* Align the other OKs

* Add an else statement to overallState

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Zookeeper Lee
2025-05-30 15:50:05 -04:00
committed by GitHub
parent 5fccaad0e7
commit 1c07e8af5b
4 changed files with 69 additions and 24 deletions

View File

@ -823,7 +823,7 @@ test('theme persists', async ({ page, context, homePage }) => {
uploadThroughput: -1, uploadThroughput: -1,
}) })
await expect(networkToggle).toContainText('Connected') await expect(networkToggle).toContainText('Network health (Strong)')
await expect(page.getByText('building scene')).not.toBeVisible() await expect(page.getByText('building scene')).not.toBeVisible()

View File

@ -14,8 +14,10 @@ test.describe('Test network related behaviors', () => {
'simulate network down and network little widget', 'simulate network down and network little widget',
{ tag: '@skipLocalEngine' }, { tag: '@skipLocalEngine' },
async ({ page, homePage }) => { async ({ page, homePage }) => {
const networkToggleConnectedText = page.getByText('Connected') const networkToggleConnectedText = page.getByText(
const networkToggleWeakText = page.getByText('Network health (Weak)') 'Network health (Strong)'
)
const networkToggleWeakText = page.getByText('Network health (Ok)')
const u = await getUtils(page) const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 }) await page.setBodyDimensions({ width: 1200, height: 500 })
@ -98,8 +100,10 @@ test.describe('Test network related behaviors', () => {
{ tag: '@skipLocalEngine' }, { tag: '@skipLocalEngine' },
async ({ page, homePage, toolbar, scene, cmdBar }) => { async ({ page, homePage, toolbar, scene, cmdBar }) => {
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId('network-toggle')
const networkToggleConnectedText = page.getByText('Connected') const networkToggleConnectedText = page.getByText(
const networkToggleWeakText = page.getByText('Network health (Weak)') 'Network health (Strong)'
)
const networkToggleWeakText = page.getByText('Network health (Ok)')
const u = await getUtils(page) const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 }) await page.setBodyDimensions({ width: 1200, height: 500 })
@ -282,8 +286,10 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34])
{ tag: ['@desktop', '@skipLocalEngine'] }, { tag: ['@desktop', '@skipLocalEngine'] },
async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => {
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId('network-toggle')
const networkToggleConnectedText = page.getByText('Connected') const networkToggleConnectedText = page.getByText(
const networkToggleWeakText = page.getByText('Network health (Weak)') 'Network health (Strong)'
)
const networkToggleWeakText = page.getByText('Network health (Ok)')
if (!tronApp) { if (!tronApp) {
fail() fail()

View File

@ -10,8 +10,8 @@ import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils' import { toSync } from '@src/lib/utils'
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = { export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
[NetworkHealthState.Ok]: 'Connected', [NetworkHealthState.Ok]: 'Strong',
[NetworkHealthState.Weak]: 'Weak', [NetworkHealthState.Weak]: 'Ok',
[NetworkHealthState.Issue]: 'Problem', [NetworkHealthState.Issue]: 'Problem',
[NetworkHealthState.Disconnected]: 'Offline', [NetworkHealthState.Disconnected]: 'Offline',
} }
@ -53,8 +53,8 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50', bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
}, },
[NetworkHealthState.Weak]: { [NetworkHealthState.Weak]: {
icon: 'text-warn-80 dark:text-warn-10', icon: 'text-succeed-50 dark:text-succeed-30',
bg: 'bg-warn-10 dark:bg-warn-80/80', bg: 'bg-lime-300/70 dark:bg-lime-300/30',
}, },
[NetworkHealthState.Issue]: { [NetworkHealthState.Issue]: {
icon: 'text-destroy-80 dark:text-destroy-10', icon: 'text-destroy-80 dark:text-destroy-10',

View File

@ -48,7 +48,8 @@ export function useNetworkStatus() {
const [overallState, setOverallState] = useState<NetworkHealthState>( const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Disconnected NetworkHealthState.Disconnected
) )
const [ping, setPing] = useState<undefined | number>(undefined) const [pingRaw, setPingRaw] = useState<undefined | number>(undefined)
const [pingEMA, setPingEMA] = useState<undefined | number>(undefined)
const [hasCopied, setHasCopied] = useState<boolean>(false) const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined) const [error, setError] = useState<ErrorType | undefined>(undefined)
@ -66,16 +67,54 @@ export function useNetworkStatus() {
const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined) const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined)
useEffect(() => { useEffect(() => {
setOverallState( if (immediateState.type === EngineConnectionStateType.Disconnecting) {
!internetConnected // Reset our running average.
? NetworkHealthState.Disconnected setPingRaw(undefined)
: hasIssues || hasIssues === undefined setPingEMA(undefined)
? NetworkHealthState.Issue }
: (ping ?? 0) > 16.6 * 3 // we consider ping longer than 3 frames as weak }, [immediateState])
? NetworkHealthState.Weak
: NetworkHealthState.Ok useEffect(() => {
) if (!pingRaw) return
}, [hasIssues, internetConnected, ping])
// We use an exponential running average to smooth out ping values.
const pingDataPointsToConsider = 10
const multiplier = 2 / (pingDataPointsToConsider + 1)
let pingEMANext = ((pingEMA ?? 0) + pingRaw) / 2
pingEMANext = pingEMANext * multiplier + (pingEMA ?? 0) * (1 - multiplier)
setPingEMA(pingEMANext)
}, [pingRaw])
useEffect(() => {
// We consider ping longer than 3 frames as weak
const WEAK_PING = 16.6 * 3
const OK_PING = 16.6 * 2
// A is used in the literature to specify the "window" of switching
const A = 1.25
const CENTER = (WEAK_PING + OK_PING) / 2
const THRESHOLD_GOOD = CENTER / A // Lower bound
const THRESHOLD_WEAK = CENTER * A // Upper bound
let nextOverallState = overallState
if (!internetConnected) {
nextOverallState = NetworkHealthState.Disconnected
} else if (hasIssues || hasIssues === undefined) {
nextOverallState = NetworkHealthState.Issue
} else if (pingEMA && pingEMA < THRESHOLD_GOOD) {
nextOverallState = NetworkHealthState.Ok
} else if (pingEMA && pingEMA > THRESHOLD_WEAK) {
nextOverallState = NetworkHealthState.Weak
} else {
nextOverallState = NetworkHealthState.Ok
}
if (nextOverallState === overallState) return
setOverallState(nextOverallState)
}, [hasIssues, internetConnected, pingEMA, overallState])
useEffect(() => { useEffect(() => {
const onlineCallback = () => { const onlineCallback = () => {
@ -126,7 +165,7 @@ export function useNetworkStatus() {
useEffect(() => { useEffect(() => {
const onPingPongChange = ({ detail: state }: CustomEvent) => { const onPingPongChange = ({ detail: state }: CustomEvent) => {
setPing(state) setPingRaw(state)
} }
const onConnectionStateChange = ({ const onConnectionStateChange = ({
@ -231,6 +270,6 @@ export function useNetworkStatus() {
error, error,
setHasCopied, setHasCopied,
hasCopied, hasCopied,
ping, ping: pingEMA !== undefined ? Math.trunc(pingEMA) : pingEMA,
} }
} }