fix(settings): Preferences stopped lagging behind the keyboard

Typing a cluster macro cost two things per character. The state lived on
SettingsModal, so every keystroke in one of those twenty-four boxes
re-rendered the WHOLE preferences dialog — every list, every form, every
panel. And the save wrote through to Go and into the database on each
one: a round trip per character.

The editor is now its own module-scoped component with its own state, so
a keystroke re-renders twelve rows. And writeUiPrefDebounced holds the
database write until the typing stops, while the local cache — which is
what everything reads back — is still written at once. Pending values are
flushed when the page goes away, so typing and immediately closing does
not lose the last word.

The behaviour is unchanged: still saved as you type, still no Save
button, because a text box whose contents only take effect on some other
button is how work gets lost.

Also folds the satellite changelog into one [NEW] entry. Satellites are
new in this version — nobody reading the notes has seen any of it — so a
running account of how it was built, tab then tracking then rotator then
where the settings moved to, is the wrong shape. One entry saying what it
does.
This commit is contained in:
2026-09-07 23:25:46 +02:00
parent fa6e30545a
commit 7ff0c2ac69
4 changed files with 117 additions and 74 deletions
+6 -2
View File
@@ -10,7 +10,7 @@
// Stored through writeUiPref like every other portable preference, so the
// buttons travel with data/ rather than living in one browser profile.
import { writeUiPref } from '@/lib/uiPref';
import { writeUiPrefDebounced } from '@/lib/uiPref';
export type ClusterMacro = {
label: string; // what the button says
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
return out;
}
// Debounced, because this is called on every keystroke in twenty-four text
// boxes. The local cache is written at once — it is what everything reads back
// — and only the database write waits for the typing to stop. A round trip into
// Go per character is what "the letters appear after I have moved on" was.
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
+44
View File
@@ -93,6 +93,50 @@ export async function syncPortablePrefs(): Promise<void> {
}));
}
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
// TYPES.
//
// The local cache is written at once, because that is what the interface reads
// back and it costs nothing. The DATABASE write is held until the typing stops:
// writeUiPref crosses into Go and writes a row, and doing that per character in
// a text box is a round trip per keystroke — twenty-four boxes of cluster
// macros was exactly that, and it showed as characters appearing after the
// finger had left the key.
//
// Pending writes are flushed when the page goes away, so a value typed and
// immediately followed by a close is not lost.
const pendingPrefs = new Map<string, { value: string; timer: number }>();
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
const prev = pendingPrefs.get(key);
if (prev) window.clearTimeout(prev.timer);
const timer = window.setTimeout(() => {
pendingPrefs.delete(key);
SetUIPref(key, value).catch((e: any) => {
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
});
}, ms);
pendingPrefs.set(key, { value, timer });
}
// flushUiPrefs writes every pending value immediately.
export function flushUiPrefs(): void {
for (const [key, p] of pendingPrefs) {
window.clearTimeout(p.timer);
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
}
pendingPrefs.clear();
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushUiPrefs);
// Closing the app does not always fire beforeunload in a WebView; a hidden
// page is the earlier and more reliable signal.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushUiPrefs();
});
}
// writeUiPref write-throughs a value to the local cache AND the portable DB.
// Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void {