perf(settings): every field in Preferences types into itself first
Preferences is one component holding two hundred pieces of state, and its
biggest panels are eight hundred lines of form — external services, CAT.
A plain controlled input sends every keystroke into that state, so every
character re-rendered the whole dialog, and the letter arrived after the
finger had left the key.
BufferedInput keeps the text where it is typed and hands it up 120 ms
later. A drop-in for Input, so the change is one import rather than a
hundred call sites — which means it has to be right in every shape those
call sites take:
• Blur flushes at once, so clicking Save cannot lose the last word, 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 with the focus in the field. That is what keeps the fields that
normalise as you type working — the callsign box that upper-cases,
the 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.
• Checkboxes, colour pickers and file fields pass straight through.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ 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<number | undefined>(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<React.ChangeEvent<HTMLInputElement> | 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 <Input ref={ref} type={type} value={value} onChange={onChange} onBlur={onBlur} onFocus={onFocus} {...props} />;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type={type}
|
||||
value={local}
|
||||
onFocus={(e) => { 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<HTMLInputElement>;
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(flush, DEBOUNCE_MS);
|
||||
}}
|
||||
onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
BufferedInput.displayName = 'BufferedInput';
|
||||
Reference in New Issue
Block a user