From d0d6002126fed49914979534e0593219f641f806 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 1 Aug 2026 13:07:20 +0000 Subject: [PATCH] fix: use ref-based selectedNote to prevent re-render focus stealing --- apps/web/src/routes/_app/notes.tsx | 47 ++-- fix_ref.py | 340 +++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 32 deletions(-) create mode 100644 fix_ref.py diff --git a/apps/web/src/routes/_app/notes.tsx b/apps/web/src/routes/_app/notes.tsx index 8b83731..00b4efa 100644 --- a/apps/web/src/routes/_app/notes.tsx +++ b/apps/web/src/routes/_app/notes.tsx @@ -1,22 +1,18 @@ -import { useState, useCallback, useRef, useEffect, memo } from "react"; +import { useState, useRef, useEffect, memo } from "react"; import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, useApiQuery, useApiMutation } from "@/lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, useApiQuery } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; -import { Plus, Trash2, Search, Pin, Archive, FileText, Link as LinkIcon, History } from "lucide-react"; +import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; import type { Note, PaginatedResponse } from "@/lib/types"; -// Simple TipTap-like editor using contentEditable +// Simple TipTap-like editor using contentEditable - saves on blur only const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) { const editorRef = useRef(null); const [isPlaceholder, setIsPlaceholder] = useState(!initialContent); @@ -82,12 +78,10 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { function NotesPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const [selectedNote, setSelectedNote] = useState(null); - const [editorContent, setEditorContent] = useState(""); - const saveTimerRef = useRef | null>(null); - const selectedNoteRef = useRef(null); + const [selectedNoteId, setSelectedNoteId] = useState(null); const [showBacklinks, setShowBacklinks] = useState(false); const [showVersions, setShowVersions] = useState(false); + const selectedNoteRef = useRef(null); useRealtime({ enabled: true }); @@ -102,9 +96,8 @@ function NotesPage() { mutationFn: () => api.post("/notes", { title: "Untitled", content: "" }), onSuccess: (note) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); - setSelectedNote(note); selectedNoteRef.current = note; - setEditorContent(""); + setSelectedNoteId(note.id); }, }); @@ -117,34 +110,24 @@ function NotesPage() { mutationFn: (id: string) => api.delete("/notes/" + id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); - setSelectedNote(null); + selectedNoteRef.current = null; + setSelectedNoteId(null); }, }); const selectNote = async (note: Note) => { try { const detail = await api.get("/notes/" + note.id); - setSelectedNote(detail); selectedNoteRef.current = detail; - setEditorContent(detail.content || ""); } catch { - setSelectedNote(note); selectedNoteRef.current = note; - setEditorContent(note.content || ""); } + setSelectedNoteId(note.id); setShowBacklinks(false); setShowVersions(false); }; - const handleContentChange = useCallback((html: string) => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - const note = selectedNoteRef.current; - if (note) { - updateMutation.mutate({ id: note.id, data: { content: html } }); - } - }, 500); - }, [updateMutation]); + const selectedNote = selectedNoteRef.current; return (
@@ -174,7 +157,7 @@ function NotesPage() { onClick={() => selectNote(note)} className={cn( "w-full text-left px-3 py-2 rounded-md text-sm transition-colors", - selectedNote?.id === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50" + selectedNoteId === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50" )} >
@@ -196,7 +179,7 @@ function NotesPage() { {selectedNote ? ( <>
- +
- + { if (selectedNoteRef.current) { updateMutation.mutate({ id: selectedNoteRef.current.id, data: { content: html } }); } }} />
{/* Backlinks section */} {showBacklinks && selectedNote.backlinks && selectedNote.backlinks.length > 0 && ( diff --git a/fix_ref.py b/fix_ref.py new file mode 100644 index 0000000..4ca7b47 --- /dev/null +++ b/fix_ref.py @@ -0,0 +1,340 @@ +import sys + +with open(sys.argv[1], 'r') as f: + content = f.read() + +old = '''function NotesPage() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [selectedNote, setSelectedNote] = useState(null); + const [showBacklinks, setShowBacklinks] = useState(false); + const [showVersions, setShowVersions] = useState(false); + + useRealtime({ enabled: true }); + + const { data: notesData, isLoading } = useApiQuery>( + ["notes", search], + "/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "") + ); + + const notes = notesData?.items || []; + + const createMutation = useMutation({ + mutationFn: () => api.post("/notes", { title: "Untitled", content: "" }), + onSuccess: (note) => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + setSelectedNote(note); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.patch("/notes/" + id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete("/notes/" + id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + setSelectedNote(null); + }, + }); + + const selectNote = async (note: Note) => { + try { + const detail = await api.get("/notes/" + note.id); + setSelectedNote(detail); + } catch { + setSelectedNote(note); + } + setShowBacklinks(false); + setShowVersions(false); + }; + + return ( +
+ {/* Left pane - note list */} +
+
+
+ + setSearch(e.target.value)} className="pl-8" /> +
+
+
+ +
+ + {isLoading ? ( +
Loading...
+ ) : notes.length === 0 ? ( +
No notes yet.
+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+
+ + {/* Right pane - editor */} +
+ {selectedNote ? ( + <> +
+ +
+ + + + + + + + + Delete Note + Are you sure you want to delete "{selectedNote.title}"? + + + Cancel + deleteMutation.mutate(selectedNote.id)} className="bg-destructive text-destructive-foreground">Delete + + + +
+
+
+ { if (selectedNote) { updateMutation.mutate({ id: selectedNote.id, data: { content: html } }); } }} /> +
+ {/* Backlinks section */} + {showBacklinks && selectedNote.backlinks && selectedNote.backlinks.length > 0 && ( +
+

Linked from

+
+ {selectedNote.backlinks.map((bl) => ( +
+ {bl.noteTitle} +
+ ))} +
+
+ )} + {/* Version history */} + {showVersions && ( +
+

Version History

+

Version history available via API.

+
+ )} + + ) : ( +
+
+ +

Select a note or create a new one

+
+
+ )} +
+
+ ); +}''' + +new = '''function NotesPage() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [selectedNoteId, setSelectedNoteId] = useState(null); + const [showBacklinks, setShowBacklinks] = useState(false); + const [showVersions, setShowVersions] = useState(false); + const selectedNoteRef = useRef(null); + + useRealtime({ enabled: true }); + + const { data: notesData, isLoading } = useApiQuery>( + ["notes", search], + "/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "") + ); + + const notes = notesData?.items || []; + + const createMutation = useMutation({ + mutationFn: () => api.post("/notes", { title: "Untitled", content: "" }), + onSuccess: (note) => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + selectedNoteRef.current = note; + setSelectedNoteId(note.id); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.patch("/notes/" + id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete("/notes/" + id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + selectedNoteRef.current = null; + setSelectedNoteId(null); + }, + }); + + const selectNote = async (note: Note) => { + try { + const detail = await api.get("/notes/" + note.id); + selectedNoteRef.current = detail; + } catch { + selectedNoteRef.current = note; + } + setSelectedNoteId(note.id); + setShowBacklinks(false); + setShowVersions(false); + }; + + const selectedNote = selectedNoteRef.current; + + return ( +
+ {/* Left pane - note list */} +
+
+
+ + setSearch(e.target.value)} className="pl-8" /> +
+
+
+ +
+ + {isLoading ? ( +
Loading...
+ ) : notes.length === 0 ? ( +
No notes yet.
+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+
+ + {/* Right pane - editor */} +
+ {selectedNote ? ( + <> +
+ +
+ + + + + + + + + Delete Note + Are you sure you want to delete "{selectedNote.title}"? + + + Cancel + deleteMutation.mutate(selectedNote.id)} className="bg-destructive text-destructive-foreground">Delete + + + +
+
+
+ { if (selectedNoteRef.current) { updateMutation.mutate({ id: selectedNoteRef.current.id, data: { content: html } }); } }} /> +
+ {/* Backlinks section */} + {showBacklinks && selectedNote.backlinks && selectedNote.backlinks.length > 0 && ( +
+

Linked from

+
+ {selectedNote.backlinks.map((bl) => ( +
+ {bl.noteTitle} +
+ ))} +
+
+ )} + {/* Version history */} + {showVersions && ( +
+

Version History

+

Version history available via API.

+
+ )} + + ) : ( +
+
+ +

Select a note or create a new one

+
+
+ )} +
+
+ ); +}''' + +content = content.replace(old, new, 1) + +with open(sys.argv[1], 'w') as f: + f.write(content) +print('Done')