fix: codex S2 review — race conditions, try/finally, scroll, compose reset

- channel switch clears messages immediately, prevents stale fetch overwrite
- auto-scroll only on NEW messages (not every poll cycle)
- ComposeBox keyed by channelId — resets draft on switch
- try/finally on all mutations — failed sends don't disable compose
- loadChannels no longer re-fetches on every channel select

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 19:30:00 +02:00
parent 20196c23a8
commit 9e35984813
3 changed files with 53 additions and 28 deletions

View File

@@ -12,35 +12,52 @@ export default function App() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [replyTo, setReplyTo] = useState<string | null>(null); const [replyTo, setReplyTo] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef(0);
const activeChannelRef = useRef(activeChannelId);
// Keep ref in sync
activeChannelRef.current = activeChannelId;
const loadChannels = useCallback(async () => { const loadChannels = useCallback(async () => {
const chs = await getChannels(); const chs = await getChannels();
setChannels(chs); setChannels(chs);
if (!activeChannelId && chs.length > 0) { // Auto-select first channel only if none selected
setActiveChannelId(chs[0].id); setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
} }, []);
}, [activeChannelId]);
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
if (!activeChannelId) return; const channelId = activeChannelRef.current;
const msgs = await getMessages(activeChannelId); if (!channelId) return;
try {
const msgs = await getMessages(channelId);
// Only update if we're still on the same channel (prevent race)
if (activeChannelRef.current === channelId) {
setMessages(msgs); setMessages(msgs);
}, [activeChannelId]); }
} catch {
// Silently ignore fetch errors during polling
}
}, []);
// Initial channel load
useEffect(() => { useEffect(() => {
loadChannels(); loadChannels();
}, [loadChannels]); }, [loadChannels]);
// Load messages on channel switch
useEffect(() => { useEffect(() => {
loadMessages(); setMessages([]); // Clear immediately on switch
setReplyTo(null); setReplyTo(null);
}, [loadMessages]); prevMsgCountRef.current = 0;
loadMessages();
}, [activeChannelId, loadMessages]);
// Auto-scroll on new messages // Auto-scroll only when NEW messages arrive (not on every poll)
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (messages.length > prevMsgCountRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
prevMsgCountRef.current = messages.length;
}, [messages]); }, [messages]);
// Poll until WebSocket (S5) // Poll until WebSocket (S5)
@@ -51,13 +68,14 @@ export default function App() {
const messagesById = new Map(messages.map((m) => [m.id, m])); const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId); const activeChannel = channels.find((c) => c.id === activeChannelId);
const _currentUser = getCurrentUsername();
return ( return (
<div className="flex h-full"> <div className="flex h-full">
<ChannelSidebar <ChannelSidebar
channels={channels} channels={channels}
activeId={activeChannelId} activeId={activeChannelId}
onSelect={(id) => setActiveChannelId(id)} onSelect={setActiveChannelId}
onChannelCreated={loadChannels} onChannelCreated={loadChannels}
/> />
@@ -105,9 +123,10 @@ export default function App() {
))} ))}
</div> </div>
{/* Compose */} {/* Compose — key forces reset on channel switch */}
{activeChannelId && ( {activeChannelId && (
<ComposeBox <ComposeBox
key={activeChannelId}
channelId={activeChannelId} channelId={activeChannelId}
replyTo={replyTo} replyTo={replyTo}
onClearReply={() => setReplyTo(null)} onClearReply={() => setReplyTo(null)}

View File

@@ -21,10 +21,13 @@ export function ChannelSidebar({
async function handleCreate() { async function handleCreate() {
if (!newName.trim()) return; if (!newName.trim()) return;
setCreating(true); setCreating(true);
try {
await createChannel({ name: newName.trim(), description: "" }); await createChannel({ name: newName.trim(), description: "" });
setNewName(""); setNewName("");
setCreating(false);
onChannelCreated(); onChannelCreated();
} finally {
setCreating(false);
}
} }
return ( return (

View File

@@ -30,6 +30,7 @@ export function ComposeBox({
async function handleSend() { async function handleSend() {
if (!content.trim() || sending) return; if (!content.trim() || sending) return;
setSending(true); setSending(true);
try {
await postMessage(channelId, { await postMessage(channelId, {
content: content.trim(), content: content.trim(),
type: msgType, type: msgType,
@@ -39,8 +40,10 @@ export function ComposeBox({
setMsgType("text"); setMsgType("text");
onClearReply(); onClearReply();
onMessageSent(); onMessageSent();
} finally {
setSending(false); setSending(false);
} }
}
return ( return (
<div className="border-t border-border bg-card px-4 py-3"> <div className="border-t border-border bg-card px-4 py-3">