fix: strictly isolate trash view from normal views and prevent stale state leakage
This commit is contained in:
1 parent
c355e2dfe3
commit
507302e6a6
1 file changed
+73
-21
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user