diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index acfe91b..3fc0576 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -74,7 +74,11 @@ import { } from '@/components/ui/dialog'; import { WebPublishPanel } from '@/components/WebPublishPanel'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +// Every text field in Preferences types into itself and hands the value up a +// moment later. This dialog is one component with two hundred pieces of state +// and panels eight hundred lines long, so a plain controlled input re-rendered +// the whole thing per character — see BufferedInput. +import { BufferedInput as Input } from '@/components/ui/buffered-input'; import { Combobox } from '@/components/ui/combobox'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; diff --git a/frontend/src/components/ui/buffered-input.tsx b/frontend/src/components/ui/buffered-input.tsx new file mode 100644 index 0000000..a9a9ed1 --- /dev/null +++ b/frontend/src/components/ui/buffered-input.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { Input } from '@/components/ui/input'; + +// A text field that types into itself first. +// +// Preferences is one component holding two hundred pieces of state, and its +// biggest panels are eight hundred lines of form. A plain controlled input +// sends every keystroke into that state, so every character re-renders the +// whole dialog — the external-services panel, the CAT panel — and the letter +// appears after the finger has left the key. +// +// This keeps the text where it is being typed and hands it up shortly after. +// The value shown is the operator's, immediately; the parent's copy catches up +// a moment later, which is soon enough for anything that reads it — nothing in +// a settings form acts on a half-typed word. +// +// It is a drop-in for Input, on purpose: the fix is a changed import, not a +// hundred edited call sites. Which means it has to behave correctly in every +// shape those call sites take: +// +// • Blur flushes at once, so clicking Save cannot lose the last word typed, +// and so does unmounting — a panel changed mid-word still hands up what +// was there. +// • A value that comes back DIFFERENT from what was sent up is adopted, even +// while the field has focus. That is how the fields which normalise as you +// type keep working: a callsign box that upper-cases, a port box that +// drops everything but digits. They echo a corrected value, and the +// correction wins. +// • A value changed from outside while the field is idle wins too — that is +// how loading the settings, or switching profile, refills the form. +// • Types that are not text — checkbox, colour, file — pass straight +// through. There is no typing to buffer and their events are not text. +const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']); + +// Short enough that a normalising field corrects itself while the operator is +// still on the same word, long enough that a burst of typing is one render. +const DEBOUNCE_MS = 120; + +export const BufferedInput = React.forwardRef>( + ({ value, onChange, onBlur, onFocus, type, ...props }, ref) => { + const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text'); + const incoming = String(value ?? ''); + const [local, setLocal] = React.useState(incoming); + const focused = React.useRef(false); + const timer = React.useRef(undefined); + // What we last handed up. Anything else arriving from the parent is the + // parent's own doing — a normalisation, a reload — and it wins. + const emitted = React.useRef(incoming); + const pending = React.useRef | null>(null); + const onChangeRef = React.useRef(onChange); + React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + + React.useEffect(() => { + if (!focused.current || incoming !== emitted.current) { + setLocal(incoming); + emitted.current = incoming; + } + }, [incoming]); + + const flush = React.useCallback(() => { + window.clearTimeout(timer.current); + timer.current = undefined; + const e = pending.current; + pending.current = null; + if (e) { + emitted.current = e.target.value; + onChangeRef.current?.(e); + } + }, []); + + // Unmounted mid-word — the panel changed, the dialog closed — still hands + // up what was typed. + React.useEffect(() => () => { + window.clearTimeout(timer.current); + if (pending.current) onChangeRef.current?.(pending.current); + }, []); + + if (!buffered) { + return ; + } + return ( + { focused.current = true; onFocus?.(e); }} + onChange={(e) => { + const v = e.target.value; + setLocal(v); + // The element's value changes again before the timer fires, so what + // matters is copied out of it now. + pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent; + window.clearTimeout(timer.current); + timer.current = window.setTimeout(flush, DEBOUNCE_MS); + }} + onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }} + {...props} + /> + ); + }, +); +BufferedInput.displayName = 'BufferedInput';