refactor(native-chat): tidy the rail wake hook and cover it with tests (#39477)

Store absolute deadlines instead of two last-seen timestamps, and let
scroll events with no wake pending return before reading the clock.
Behaviour is unchanged.
This commit is contained in:
Neil
2026-11-07 11:27:47 +00:00
committed by GitHub
parent 2d79ec271c
commit aa3cf3b027
2 changed files with 81 additions and 7 deletions
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useNativeChatRailWake } from './use-native-chat-rail-wake'
function setup() {
const hook = renderHook(() => useNativeChatRailWake())
const rail = document.createElement('div')
hook.result.current.railRef.current = rail
return { ...hook.result.current, rail, unmount: hook.unmount }
}
describe('useNativeChatRailWake', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('lights on input and goes dark after the wake window', () => {
const { rail, wake } = setup()
wake()
expect(rail.dataset.awake).toBe('')
vi.advanceTimersByTime(299)
expect('awake' in rail.dataset).toBe(true)
vi.advanceTimersByTime(1)
expect('awake' in rail.dataset).toBe(false)
})
it('stays lit while scroll events follow a recent input', () => {
const { rail, wake, sustain } = setup()
wake()
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(100)
sustain()
}
expect('awake' in rail.dataset).toBe(true)
vi.advanceTimersByTime(300)
expect('awake' in rail.dataset).toBe(false)
})
it('stops sustaining once the input is too old', () => {
const { rail, wake, sustain } = setup()
wake()
for (let i = 0; i < 20; i++) {
vi.advanceTimersByTime(100)
sustain()
}
// 1.5 s after input, scroll events no longer extend the wake.
expect('awake' in rail.dataset).toBe(false)
})
it('ignores scroll events with no wake pending', () => {
const { rail, sustain } = setup()
sustain()
vi.advanceTimersByTime(1000)
expect('awake' in rail.dataset).toBe(false)
})
it('clears its timer on unmount', () => {
const { wake, unmount } = setup()
wake()
unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -17,12 +17,15 @@ export function useNativeChatRailWake(): {
} {
const railRef = useRef<HTMLDivElement | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastWake = useRef(0)
const lastInput = useRef(0)
/** When the rail may go dark again. */
const sleepAt = useRef(0)
/** Scroll events before this still belong to the reader's last input. */
const sustainUntil = useRef(0)
const wake = useCallback(() => {
lastWake.current = performance.now()
lastInput.current = lastWake.current
const now = performance.now()
sleepAt.current = now + NATIVE_CHAT_RAIL_WAKE_MS
sustainUntil.current = now + NATIVE_CHAT_RAIL_SUSTAIN_MS
const rail = railRef.current
// Why: a gesture fires wheel events every frame; write the DOM and arm a timer once per gesture.
if (rail && !('awake' in rail.dataset)) {
@@ -32,7 +35,7 @@ export function useNativeChatRailWake(): {
return
}
const settle = (): void => {
const remaining = lastWake.current + NATIVE_CHAT_RAIL_WAKE_MS - performance.now()
const remaining = sleepAt.current - performance.now()
if (remaining > 0) {
timer.current = setTimeout(settle, remaining)
return
@@ -46,9 +49,13 @@ export function useNativeChatRailWake(): {
}, [])
const sustain = useCallback(() => {
// Why: most scroll events (streaming, auto-follow) arrive with no wake pending; skip the clock read.
if (timer.current === null) {
return
}
const now = performance.now()
if (timer.current !== null && now - lastInput.current < NATIVE_CHAT_RAIL_SUSTAIN_MS) {
lastWake.current = now
if (now < sustainUntil.current) {
sleepAt.current = now + NATIVE_CHAT_RAIL_WAKE_MS
}
}, [])