Files
sencho/frontend/src/components/sidebar/SidebarSearch.tsx
T
Anso 0a8e6a79ae fix(sidebar): cancel pending debounce emit on external value reset (#1244)
SidebarSearch's value-sync effect adopted external resets but left the
pending setTimeout in place. When a clear or filter-driven reset arrived
inside the 120ms window, the stale timer would fire after the adopt and
emit the previously-typed query back to the parent, silently undoing the
reset. The skip condition also leaned on lastEmittedRef, which kept a
genuine reset from winning if its value happened to equal the last emit.

Switch the skip to compare the parent value against the locally shown
value (tracked through a ref so the effect deps stay on [value]). On any
external transition the effect now clears the pending timer before
adopting, removing the race entirely. lastEmittedRef is dead under this
model and is removed.

Adds a fake-timer test that types mid-window, rerenders with a different
value before the debounce fires, advances past the original deadline, and
asserts the parent never receives the stale emit.
2026-05-28 15:27:26 -04:00

67 lines
2.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CommandInput } from '@/components/ui/command';
interface SidebarSearchProps {
value: string;
onValueChange: (v: string) => void;
}
// 120ms feels instant to a typist (still under the ~150ms human reaction
// floor) while collapsing a burst of keystrokes into one filter rebuild.
// `<Command shouldFilter={false}>` means useStackListState owns the actual
// filter pass; debouncing here directly cuts its rebuild count.
const DEBOUNCE_MS = 120;
export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) {
const [local, setLocalState] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// localRef mirrors `local` for the value-sync effect. Reading state via the
// ref keeps the effect deps on [value] without dropping a real read of
// `local`, which would either lie to React or trigger spurious re-runs.
const localRef = useRef(value);
const setLocal = useCallback((next: string) => {
localRef.current = next;
setLocalState(next);
}, []);
useEffect(() => {
// The parent value moved. Skip only when it already matches what's shown
// locally: that is the post-emit steady state (the debounce echo settled
// back through the parent). Any other movement is an external change
// (clear-on-filter-change, navigation restore, programmatic set, or a
// coincidence) and must win: cancel any in-flight emit so it cannot undo
// the reset, then adopt the value.
if (value === localRef.current) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setLocal(value);
}, [value, setLocal]);
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
}, []);
const handleChange = (next: string) => {
setLocal(next);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
onValueChange(next);
}, DEBOUNCE_MS);
};
return (
<div className="px-4 py-2 flex-none">
<CommandInput
placeholder="Search stacks..."
value={local}
onValueChange={handleChange}
className="h-9 border-none"
/>
</div>
);
}