From 5d3af168b3619d755cd7efa42c5e585d37fa345b Mon Sep 17 00:00:00 2001 From: efiten Date: Sun, 13 Sep 2026 23:13:27 +0200 Subject: [PATCH] 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=` shows the pubkey in the field, as before. --------- Co-authored-by: Claude Opus 5 (1M context) --- public/live.js | 7 ++++++- test-live.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/public/live.js b/public/live.js index f44fb054..4b79aac0 100644 --- a/public/live.js +++ b/public/live.js @@ -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'); diff --git a/test-live.js b/test-live.js index 5df12ef3..fe6cb601 100644 --- a/test-live.js +++ b/test-live.js @@ -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) =====