mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
0a8e6a79ae
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.
67 lines
2.3 KiB
TypeScript
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>
|
|
);
|
|
}
|