Fix mobile performance degradation during typing and after image upload #21

Merged
ryan merged 1 commits from fix/mobile-input-performance into main 2026-04-05 06:59:40 -04:00
2 changed files with 36 additions and 17 deletions
Showing only changes of commit e644def141 - Show all commits

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react"; import { useCallback, useEffect, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react"; import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { conversationService } from "../api/conversationService"; import { conversationService } from "../api/conversationService";
import { userService } from "../api/userService"; import { userService } from "../api/userService";
@@ -63,9 +63,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = () => { const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); requestAnimationFrame(() => {
}; messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth",
});
});
}, [isLoading]);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@@ -130,7 +134,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
load(); load();
}, [selectedConversation?.id]); }, [selectedConversation?.id]);
const handleQuestionSubmit = async () => { const handleQuestionSubmit = useCallback(async () => {
if ((!query.trim() && !pendingImage) || isLoading) return; if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation; let activeConversation = selectedConversation;
@@ -214,19 +218,22 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
if (isMountedRef.current) setIsLoading(false); if (isMountedRef.current) setIsLoading(false);
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}; }, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value); setQuery(event.target.value);
}; }, []);
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>; const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
if (kev.key === "Enter" && !kev.shiftKey) { if (kev.key === "Enter" && !kev.shiftKey) {
kev.preventDefault(); kev.preventDefault();
handleQuestionSubmit(); handleQuestionSubmit();
} }
}; }, [handleQuestionSubmit]);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
const handleClearImage = useCallback(() => setPendingImage(null), []);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
@@ -380,8 +387,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading} isLoading={isLoading}
pendingImage={pendingImage} pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)} onImageSelect={handleImageSelect}
onClearImage={() => setPendingImage(null)} onClearImage={handleClearImage}
/> />
</div> </div>
</div> </div>
@@ -416,7 +423,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div> </div>
</div> </div>
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm"> <footer className="border-t border-sand-light/40 bg-cream">
<div className="max-w-2xl mx-auto px-4 py-3"> <div className="max-w-2xl mx-auto px-4 py-3">
<MessageInput <MessageInput
query={query} query={query}

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, ImagePlus, X } from "lucide-react"; import { ArrowUp, ImagePlus, X } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
@@ -15,7 +15,7 @@ type MessageInputProps = {
onClearImage: () => void; onClearImage: () => void;
}; };
export const MessageInput = ({ export const MessageInput = React.memo(({
query, query,
handleKeyDown, handleKeyDown,
handleQueryChange, handleQueryChange,
@@ -29,6 +29,18 @@ export const MessageInput = ({
const [simbaMode, setLocalSimbaMode] = useState(false); const [simbaMode, setLocalSimbaMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Create blob URL once per file, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const toggleSimbaMode = () => { const toggleSimbaMode = () => {
const next = !simbaMode; const next = !simbaMode;
setLocalSimbaMode(next); setLocalSimbaMode(next);
@@ -59,7 +71,7 @@ export const MessageInput = ({
<div className="px-3 pt-3"> <div className="px-3 pt-3">
<div className="relative inline-block"> <div className="relative inline-block">
<img <img
src={URL.createObjectURL(pendingImage)} src={previewUrl!}
alt="Pending upload" alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand" className="h-20 rounded-lg object-cover border border-sand"
/> />
@@ -145,4 +157,4 @@ export const MessageInput = ({
</div> </div>
</div> </div>
); );
}; });