From 9c4f582eba97363807487d64de81594ee41b9717 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 12:35:41 -0600 Subject: [PATCH] fix(desktop): release stale channel autocomplete before edit submission Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- .../lib/useChannelLinks.lifecycle.test.mjs | 107 ++++++++++++++++++ .../features/messages/lib/useChannelLinks.ts | 16 +++ 2 files changed, 123 insertions(+) create mode 100644 desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs diff --git a/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs b/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs new file mode 100644 index 0000000000..e9b4ed928c --- /dev/null +++ b/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; +import * as React from "react"; + +import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; +import { useChannelLinks } from "./useChannelLinks.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); +after(() => dom.window.close()); + +const channels = [ + { id: "general", name: "general", channelType: "stream", archivedAt: null }, +]; + +function wrapper({ children }) { + return React.createElement(ChannelNavigationProvider, { channels }, children); +} + +function enterEvent() { + return { + key: "Enter", + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; +} + +for (const [name, text, cursor] of [ + ["replacing the loaded edit", "Edited, not deleted", 19], + ["clearing the loaded edit", "", 0], + ["moving before the channel reference", "Welcome to #general", 0], +]) { + test(`${name} releases Enter before the debounce expires`, async (t) => { + const { act, renderHook } = await import("@testing-library/react"); + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { result, unmount } = renderHook(useChannelLinks, { wrapper }); + act(() => result.current.updateChannelQuery("Welcome to #general", 19)); + act(() => t.mock.timers.tick(120)); + assert.equal(result.current.isChannelOpen, true); + + act(() => result.current.updateChannelQuery(text, cursor)); + assert.equal(result.current.isChannelOpen, false); + const event = enterEvent(); + assert.deepEqual(result.current.handleChannelKeyDown(event), { + handled: false, + }); + assert.equal(event.defaultPrevented, false); + act(() => t.mock.timers.tick(120)); + assert.equal(result.current.isChannelOpen, false); + unmount(); + }); +} + +test("clearing a pending query cannot reopen channel completion", async (t) => { + const { act, renderHook } = await import("@testing-library/react"); + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { result, unmount } = renderHook(useChannelLinks, { wrapper }); + act(() => result.current.updateChannelQuery("#gen", 4)); + act(() => result.current.updateChannelQuery("", 0)); + act(() => t.mock.timers.tick(120)); + assert.equal(result.current.isChannelOpen, false); + unmount(); +}); + +test("valid channel completion still debounces and consumes Enter", async (t) => { + const { act, renderHook } = await import("@testing-library/react"); + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { result, unmount } = renderHook(useChannelLinks, { wrapper }); + act(() => result.current.updateChannelQuery("See #gen", 8)); + act(() => t.mock.timers.tick(119)); + assert.equal(result.current.isChannelOpen, false); + act(() => t.mock.timers.tick(1)); + assert.equal(result.current.isChannelOpen, true); + const event = enterEvent(); + const selection = result.current.handleChannelKeyDown(event); + assert.equal(selection.handled, true); + assert.equal(event.defaultPrevented, true); + let edit; + act(() => { + edit = result.current.insertChannel(selection.suggestion, 8); + }); + assert.deepEqual(edit, { + replaceFromOffset: 4, + replaceToOffset: 8, + insertText: "#general ", + }); + assert.equal(result.current.isChannelOpen, false); + unmount(); +}); diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 4b703f53cd..129e63eda0 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -122,6 +122,22 @@ export function useChannelLinks() { if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + // Losing the #query must close immediately. Debouncing dismissal leaves + // the old suggestion owning Enter after the user replaces/clears an edit. + if ( + !detectPrefixQuery( + "#", + value, + cursorPosition, + knownNamesLowerRef.current, + ) + ) { + setChannelQuery(null); + setChannelSelectedIndex(0); + return; } debounceTimerRef.current = setTimeout(() => {