fix(live): keep the space the user is typing in the node filter (#2028)

## Problem

Typing a node name with a space slowly into the Live page node filter
glues the words together: "Dan's Local" ends up as `Dan'sLocal`, and
`/api/nodes/search` then returns no suggestions.

The debounced input handler commits the trimmed value (`public/live.js`
`applyFilterFromInput`, ~1791), so after "Dan's " the filter key is
`Dan's`. `setNodeFilter` calls `updateNodeFilterUI`, which wrote
`nodeFilterKeys.join(', ')` back into the input whenever it differed
from the raw input value (~2912). That dropped the trailing space the
user had just typed, and the next keystrokes were appended to `Dan's`.

Found while reviewing #2026.

## Change

`updateNodeFilterUI` no longer writes into the node filter field while
it has focus, and otherwise only when the trimmed input differs from the
keys. Besides the typing debounce, it runs for every matching live
packet, which could also eat a character typed inside the debounce, and
it replaced a picked suggestion's name with the pubkey. Restores from
`?node=` or localStorage (field not focused) still write the keys.

## Tests

- `test-live.js`: a trailing space typed into a focused field is kept
(fails on master); a focused field that differs from the keys is not
overwritten; an unfocused field with different text is; an unfocused
field that differs only by whitespace is not.
- Mutation: each of the three guards (focus, trimmed comparison, write
when different) fails one test on its own.
- `node test-live.js`: 100 passed. `sh test-all.sh`: all standalone
frontend suites pass. `npx eslint public/live.js`: 0 errors.

## Browser validation

Local server with the e2e fixture, headless Chromium, typing `Dan's `
(60 ms per key), a 500 ms pause, then `Local`:

| Build | Input | Stored filter | Suggestions |
|---|---|---|---|
| this branch | `Dan's Local` | `Dan's Local` | `Dan's Local Repeater` |
| master | `Dan'sLocal` | `Dan'sLocal` | none |

## Not verified

- Other browsers than Chromium, and mobile keyboards with autocorrect.
- A trailing space typed and left there: the filter key stays trimmed
while the input keeps the space, which is the intended difference.



## Review follow-up (commit `4e86171d`)

An independent review found the change correct but pointed at the same
bug on a second path, plus a weak test:

- **Packets during typing.** `updateNodeFilterUI` also runs for every
matching live packet (`public/live.js` ~3393). A packet arriving inside
the 200 ms debounce still rewrote the field: with filter `ab12` and the
user typing `3`, the `3` was lost. The write is now skipped while the
input has focus.
- **Picked suggestion.** The same write replaced the name
`selectSuggestion` had just put in the field with the node's
64-character pubkey. With the focus guard the field keeps the name; the
filter key is still the pubkey.
- **Tests.** The old "writes a different key" test started from an empty
input, so a check that only writes into an empty field passed too. The
tests now cover a focused input that differs (not overwritten), an
unfocused input with different text (overwritten) and an unfocused input
that differs only by whitespace (not overwritten). Each of the three
guards was mutated on its own and fails one test. `test-live.js`: 100
passed; `sh test-all.sh`: all standalone suites pass; eslint: 0 errors.

Browser, local server with the e2e fixture: typing `Dan's ` then `Local`
keeps `Dan's Local` with one suggestion; picking it shows `Dan's Local
Repeater` while the stored filter is the pubkey; reopening
`#/live?node=<pubkey>` shows the pubkey in the field, as before.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-09-13 23:13:27 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 52b9474d7a
commit 5d3af168b3
2 changed files with 55 additions and 1 deletions
+6 -1
View File
@@ -2909,7 +2909,12 @@
if (nodeFilterKeys.length > 0) {
if (clearBtn) clearBtn.style.display = '';
if (countEl) { countEl.textContent = `Showing ${nodeFilterShown} of ${nodeFilterTotal}`; countEl.classList.remove('hidden'); }
if (input && input.value !== nodeFilterKeys.join(', ')) input.value = nodeFilterKeys.join(', ');
// Never overwrite the field while the user is in it: this also runs from the
// debounced typing handler (which commits the trimmed value) and from every
// matching packet, and a picked suggestion shows the name while the key is
// the pubkey. Compare trimmed so a stray space alone is no reason to write.
const userIsEditing = document.activeElement === input;
if (input && !userIsEditing && input.value.trim() !== nodeFilterKeys.join(', ')) input.value = nodeFilterKeys.join(', ');
} else {
if (clearBtn) clearBtn.style.display = 'none';
if (countEl) countEl.classList.add('hidden');
+49
View File
@@ -1027,6 +1027,55 @@ console.log('\n=== live.js: node filter ===');
setFilter([]);
assert.strictEqual(ctx.localStorage.getItem('live-node-filter'), '');
});
// updateNodeFilterUI writes the filter keys into the input. It runs from the
// debounced typing handler (which commits the trimmed value) and from every
// matching live packet, so it must not overwrite what the user is typing.
function withFilterInput(value, focused, fn) {
const input = { value };
const origGet = ctx.document.getElementById;
const origActive = ctx.document.activeElement;
ctx.document.getElementById = (id) => (id === 'liveNodeFilterInput' ? input : null);
ctx.document.activeElement = focused ? input : null;
try { fn(input); } finally {
ctx.document.getElementById = origGet;
ctx.document.activeElement = origActive;
ctx.window._liveSetNodeFilter([]);
}
}
// Typing "Dan's Local" slowly: the debounce commits "Dan's"; writing that
// back dropped the space and the next word was glued on ("Dan'sLocal").
test('node filter keeps a trailing space the user is typing', () => {
withFilterInput("Dan's ", true, (input) => {
ctx.window._liveSetNodeFilter(["Dan's"]);
assert.strictEqual(input.value, "Dan's ", 'input rewritten while typing');
});
});
// A matching packet re-renders the filter UI while the user has typed a
// character the 200 ms debounce has not committed yet ("ab12" -> "ab123").
// The same guard keeps a picked suggestion's name instead of its pubkey.
test('node filter does not overwrite a focused input that differs', () => {
withFilterInput('ab123', true, (input) => {
ctx.window._liveSetNodeFilter(['ab12']);
assert.strictEqual(input.value, 'ab123', 'focused input overwritten');
});
});
test('node filter writes a different key into an unfocused input', () => {
withFilterInput('Dan', false, (input) => {
ctx.window._liveSetNodeFilter(['abcd1234', 'ef012345']);
assert.strictEqual(input.value, 'abcd1234, ef012345');
});
});
test('node filter leaves an unfocused input that differs only by whitespace', () => {
withFilterInput(' ab12 ', false, (input) => {
ctx.window._liveSetNodeFilter(['ab12']);
assert.strictEqual(input.value, ' ab12 ');
});
});
}
// ===== Clickable paths (M2 — #771) =====