fix: strictly isolate trash view from normal views and prevent stale state leakage

This commit is contained in:
chrisf committed 2026-10-22 22:19:52 -06:00
1 parent c355e2dfe3
commit 507302e6a6
1 file changed
+73 -21
+73 -21
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { formatDistanceToNow, format } from 'date-fns';
import {
@@ -222,6 +222,7 @@ export default function EvercloneWorkspace() {
// Notes list & active note detail
const [notes, setNotes] = useState<NoteSummary[]>([]);
const listRequestIdRef = useRef<number>(0);
const [totalNotes, setTotalNotes] = useState<number>(0);
const [loadingList, setLoadingList] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
@@ -393,12 +394,14 @@ export default function EvercloneWorkspace() {
const fetchNotesList = useCallback(
async (preferSelectNoteId?: string) => {
const currentReqId = ++listRequestIdRef.current;
setLoadingList(true);
try {
const sp = buildNoteQueryParams(0, 150);
const res = await fetch(`/api/notes?${sp.toString()}`);
if (!res.ok) return;
const data = await res.json();
if (currentReqId !== listRequestIdRef.current) return;
const loadedNotes: NoteSummary[] = data.notes || [];
setNotes(loadedNotes);
setTotalNotes(data.total || 0);
@@ -415,9 +418,13 @@ export default function EvercloneWorkspace() {
setActiveNote(null);
}
} catch (e) {
console.error('Failed to fetch notes:', e);
if (currentReqId === listRequestIdRef.current) {
console.error('Failed to fetch notes:', e);
}
} finally {
setLoadingList(false);
if (currentReqId === listRequestIdRef.current) {
setLoadingList(false);
}
}
},
[buildNoteQueryParams]
@@ -425,17 +432,20 @@ export default function EvercloneWorkspace() {
const fetchMoreNotes = useCallback(async () => {
if (loadingMore || !hasMoreNotes || loadingList) return;
const currentReqId = listRequestIdRef.current;
setLoadingMore(true);
try {
const sp = buildNoteQueryParams(notes.length, 150);
const res = await fetch(`/api/notes?${sp.toString()}`);
if (!res.ok) return;
const data = await res.json();
if (currentReqId !== listRequestIdRef.current) return;
const nextBatch: NoteSummary[] = data.notes || [];
if (nextBatch.length === 0) {
setHasMoreNotes(false);
} else {
setNotes((prev) => {
if (currentReqId !== listRequestIdRef.current) return prev;
const existingIds = new Set(prev.map((n) => n.id));
const unique = nextBatch.filter((n) => !existingIds.has(n.id));
const combined = [...prev, ...unique];
@@ -444,12 +454,54 @@ export default function EvercloneWorkspace() {
});
}
} catch (e) {
console.error('Failed to fetch next page of notes:', e);
if (currentReqId === listRequestIdRef.current) {
console.error('Failed to fetch next page of notes:', e);
}
} finally {
setLoadingMore(false);
if (currentReqId === listRequestIdRef.current) {
setLoadingMore(false);
}
}
}, [loadingMore, hasMoreNotes, loadingList, buildNoteQueryParams, notes.length]);
// Safety filter: In Trash view, only trashed notes appear. In all other views, trashed notes never appear.
const displayedNotes = useMemo(() => {
if (activeView === 'trash') {
return notes.filter((n) => n.isTrashed);
}
return notes.filter((n) => !n.isTrashed);
}, [notes, activeView]);
// When switching between trash and non-trash views:
// Immediately reset notes and selection to prevent any stale state or lingering trash notes
const prevViewRef = useRef(activeView);
useEffect(() => {
const wasTrash = prevViewRef.current === 'trash';
const isTrash = activeView === 'trash';
prevViewRef.current = activeView;
if (wasTrash !== isTrash) {
listRequestIdRef.current++;
setNotes([]);
setSelectedNoteId('');
setActiveNote(null);
setHasMoreNotes(false);
setLoadingList(true);
}
}, [activeView]);
// If the active note's trashed state conflicts with activeView, clear editor selection
useEffect(() => {
if (activeNote) {
if (activeView === 'trash' && !activeNote.isTrashed) {
setActiveNote(null);
setSelectedNoteId('');
} else if (activeView !== 'trash' && activeNote.isTrashed) {
setActiveNote(null);
setSelectedNoteId('');
}
}
}, [activeView, activeNote]);
useEffect(() => {
fetchSidebar();
}, [fetchSidebar]);
@@ -714,13 +766,13 @@ export default function EvercloneWorkspace() {
const deletedId = activeNote.id;
// Determine the next note to select (either the next one in the list or the previous one)
const currentIndex = notes.findIndex((n) => n.id === deletedId);
const currentIndex = displayedNotes.findIndex((n) => n.id === deletedId);
let nextNoteId = '';
if (currentIndex !== -1) {
if (currentIndex + 1 < notes.length) {
nextNoteId = notes[currentIndex + 1].id;
if (currentIndex + 1 < displayedNotes.length) {
nextNoteId = displayedNotes[currentIndex + 1].id;
} else if (currentIndex > 0) {
nextNoteId = notes[currentIndex - 1].id;
nextNoteId = displayedNotes[currentIndex - 1].id;
}
}
@@ -967,22 +1019,22 @@ export default function EvercloneWorkspace() {
};
// Card grid rows: 2 cards per row
const cardGridRowCount = Math.ceil(notes.length / 2);
const cardGridRowCount = Math.ceil(displayedNotes.length / 2);
// Virtualizer for middle note list pane (handles Snippets, Compact list, and 2-column Card grid rows)
const rowVirtualizer = useVirtualizer({
count: listMode === 'cards' ? cardGridRowCount : notes.length,
count: listMode === 'cards' ? cardGridRowCount : displayedNotes.length,
getScrollElement: () => listParentRef.current,
estimateSize: () =>
listMode === 'compact' ? 52 : listMode === 'cards' ? 240 : 112,
getItemKey: (index: number) => {
if (listMode === 'cards') {
const start = index * 2;
const n1 = notes[start]?.id || index;
const n2 = notes[start + 1]?.id || '';
const n1 = displayedNotes[start]?.id || index;
const n2 = displayedNotes[start + 1]?.id || '';
return `cards-${n1}-${n2}`;
}
return `${listMode}-${notes[index]?.id || index}`;
return `${listMode}-${displayedNotes[index]?.id || index}`;
},
overscan: 8,
});
@@ -1005,7 +1057,7 @@ export default function EvercloneWorkspace() {
useEffect(() => {
if (!virtualItems.length || !hasMoreNotes || loadingMore || loadingList) return;
const lastItem = virtualItems[virtualItems.length - 1];
const totalVirtual = listMode === 'cards' ? cardGridRowCount : notes.length;
const totalVirtual = listMode === 'cards' ? cardGridRowCount : displayedNotes.length;
if (lastItem && lastItem.index >= totalVirtual - 6) {
fetchMoreNotes();
}
@@ -1016,7 +1068,7 @@ export default function EvercloneWorkspace() {
loadingList,
listMode,
cardGridRowCount,
notes.length,
displayedNotes.length,
fetchMoreNotes,
]);
@@ -1894,7 +1946,7 @@ export default function EvercloneWorkspace() {
</button>
</>
)}
{activeView === 'trash' && notes.length > 0 && (
{activeView === 'trash' && displayedNotes.length > 0 && (
<button
type="button"
onClick={handleEmptyTrash}
@@ -2356,9 +2408,9 @@ export default function EvercloneWorkspace() {
{/* Note List Body: Card Grid vs Virtualized Snippet / Compact List */}
<div ref={listParentRef} className="flex-1 overflow-y-scroll">
{loadingList && notes.length === 0 ? (
{loadingList && displayedNotes.length === 0 ? (
<div className="p-8 text-center text-xs text-gray-400">Loading notes…</div>
) : notes.length === 0 ? (
) : displayedNotes.length === 0 ? (
<div className="p-8 text-center space-y-3">
<Sparkles className="w-8 h-8 text-gray-300 mx-auto" />
<div className="text-sm font-medium text-gray-600">No notes found</div>
@@ -2407,7 +2459,7 @@ export default function EvercloneWorkspace() {
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * 2;
const rowNotes = notes.slice(startIndex, startIndex + 2);
const rowNotes = displayedNotes.slice(startIndex, startIndex + 2);
if (rowNotes.length === 0) return null;
return (
@@ -2586,7 +2638,7 @@ export default function EvercloneWorkspace() {
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const n = notes[virtualRow.index];
const n = displayedNotes[virtualRow.index];
if (!n) return null;
const isSelected = n.id === selectedNoteId;
const firstImg = n.attachments.find((a) =>