From 285420030a01c85d2f158adfc7672d46511329d7 Mon Sep 17 00:00:00 2001 From: Fides Date: Wed, 25 Feb 2026 20:33:51 +0200 Subject: [PATCH] fix(chat): fix keyboard input lag during typing --- frontend-new/src/chat/Chat.test.tsx | 52 +++++++++++++++++++++++++++++ frontend-new/src/chat/Chat.tsx | 18 +++++----- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index 75995e709..298902e6e 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -2251,6 +2251,58 @@ describe("Chat", () => { expect(console.warn).not.toHaveBeenCalled(); }); + test("should not re-render chat list on keydown when backdrop is already hidden", async () => { + // GIVEN a logged-in user with an active session + const givenUser: TabiyaUser = getMockUser(); + AuthenticationStateService.getInstance().setUser(givenUser); + const givenActiveSessionId = 123; + UserPreferencesStateService.getInstance().setUserPreferences( + getMockUserPreferences(givenUser, givenActiveSessionId) + ); + + // AND a chat history with a message + const givenMessages: ConversationResponse = getMockConversationResponse( + [ + { + message_id: nanoid(), + message: "hello", + sent_at: new Date().toISOString(), + sender: ConversationMessageSender.USER, + reaction: null, + }, + ], + ConversationPhase.DIVE_IN, + 75 + ); + jest.spyOn(ChatService.getInstance(), "getChatHistory").mockResolvedValueOnce(givenMessages); + + // WHEN the component is mounted + render(); + + // AND the chat component is initialized + await assertChatInitialized(); + + // Capture the current render count after initialization settles + const initialRenderCount = (ChatList as jest.Mock).mock.calls.length; + + // WHEN multiple keydown events are fired (user typing) + await act(async () => { + for (let i = 0; i < 5; i++) { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + bubbles: true, + cancelable: true, + }) + ); + } + }); + + // THEN no additional ChatList renders should occur (no re-render per keystroke) + expect((ChatList as jest.Mock).mock.calls.length).toBe(initialRenderCount); + }); + test.each([ [ "click", diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index 49d00bc33..fdf4ea424 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -117,7 +117,6 @@ export const Chat: React.FC> = ({ const [isLoading, setIsLoading] = React.useState(false); const [isLoggingOut, setIsLoggingOut] = React.useState(false); const [showBackdrop, setShowBackdrop] = useState(showInactiveSessionAlert); - const [lastActivityTime, setLastActivityTime] = React.useState(Date.now()); const [newConversationDialog, setNewConversationDialog] = React.useState(false); const [exploredExperiencesNotification, setExploredExperiencesNotification] = useState(false); const [activeSessionId, setActiveSessionId] = useState( @@ -134,6 +133,7 @@ export const Chat: React.FC> = ({ timeoutId: NodeJS.Timeout }>>(new Map()); + const lastActivityRef = useRef(Date.now()); const navigate = useNavigate(); const initializingRef = useRef(false); @@ -782,7 +782,7 @@ export const Chat: React.FC> = ({ if (disableInactivityCheck || conversationCompleted) return; const checkInactivity = () => { - if (Date.now() - lastActivityTime > INACTIVITY_TIMEOUT) { + if (Date.now() - lastActivityRef.current > INACTIVITY_TIMEOUT) { setShowBackdrop(true); } }; @@ -790,22 +790,24 @@ export const Chat: React.FC> = ({ const interval = setInterval(checkInactivity, CHECK_INACTIVITY_INTERVAL); return () => clearInterval(interval); - }, [lastActivityTime, disableInactivityCheck, conversationCompleted]); + }, [disableInactivityCheck, conversationCompleted]); - // Close backdrop when user interacts with the page + // Close the backdrop when the user interacts with the page useEffect(() => { if (disableInactivityCheck) return; // Reset the timer when the user interacts with the page const resetTimer = () => { - setLastActivityTime(Date.now()); - setShowBackdrop(false); + lastActivityRef.current = Date.now(); + setShowBackdrop((prev) => (prev ? false : prev)); }; const events = ["mousedown", "keydown"]; - events.forEach((event) => document.addEventListener(event, resetTimer)); + events.forEach((event) => document.addEventListener(event, resetTimer, { passive: true })); - return () => events.forEach((event) => document.removeEventListener(event, resetTimer)); + return () => { + events.forEach((e) => document.removeEventListener(e, resetTimer)); + }; }, [disableInactivityCheck]); // Preload the "Download Report" button when experiences are explored.