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 [replyTo, setReplyTo] = useState<string | null>(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 chs = await getChannels();
setChannels(chs);
if (!activeChannelId && chs.length > 0) {
setActiveChannelId(chs[0].id);
}
}, [activeChannelId]);
// Auto-select first channel only if none selected
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
}, []);
const loadMessages = useCallback(async () => {
if (!activeChannelId) return;
const msgs = await getMessages(activeChannelId);
const channelId = activeChannelRef.current;
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);
}, [activeChannelId]);
}
} catch {
// Silently ignore fetch errors during polling
}
}, []);
// Initial channel load
useEffect(() => {
loadChannels();
}, [loadChannels]);
// Load messages on channel switch
useEffect(() => {
loadMessages();
setMessages([]); // Clear immediately on switch
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(() => {
if (scrollRef.current) {
if (messages.length > prevMsgCountRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
prevMsgCountRef.current = messages.length;
}, [messages]);
// Poll until WebSocket (S5)
@@ -51,13 +68,14 @@ export default function App() {
const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId);
const _currentUser = getCurrentUsername();
return (
<div className="flex h-full">
<ChannelSidebar
channels={channels}
activeId={activeChannelId}
onSelect={(id) => setActiveChannelId(id)}
onSelect={setActiveChannelId}
onChannelCreated={loadChannels}
/>
@@ -105,9 +123,10 @@ export default function App() {
))}
</div>
{/* Compose */}
{/* Compose — key forces reset on channel switch */}
{activeChannelId && (
<ComposeBox
key={activeChannelId}
channelId={activeChannelId}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}

View File

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

View File

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