Skip to content

Commit 455ee68

Browse files
Fix race conditions, orphaned data, and render performance
- Wrap putEmbedding in Dexie transaction to prevent duplicate embeddings - Check note existence before writing embedding to avoid orphans - Guard initSemanticMode with ref to prevent concurrent double-init - Use Dexie streaming filter for backlinks instead of loading all notes - Memoize allTags and keywordResults to avoid recomputation on every render - Exclude generated sw.js from ESLint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 56fe29e commit 455ee68

6 files changed

Lines changed: 33 additions & 15 deletions

File tree

components/BacklinksSection.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export default function BacklinksSection({ noteId, noteText, onNavigate }: Backl
2020
const backlinks = useLiveQuery(async () => {
2121
if (!title) return [];
2222
const target = `[[${title}]]`.toLowerCase();
23-
const all = await db.notes.toArray();
24-
return all.filter((n) => n.id !== noteId && n.text.toLowerCase().includes(target));
23+
return db.notes
24+
.filter((n) => n.id !== noteId && n.text.toLowerCase().includes(target))
25+
.toArray();
2526
}, [noteId, title]);
2627

2728
if (!backlinks || backlinks.length === 0) return null;

components/NotesView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useLiveQuery } from 'dexie-react-hooks';
4-
import { useState } from 'react';
4+
import { useState, useMemo } from 'react';
55
import { useRouter } from 'next/navigation';
66
import { db, archiveNote, deleteNote } from '@/lib/db';
77
import { relativeTime, truncate } from '@/lib/utils';
@@ -18,6 +18,11 @@ export default function NotesView() {
1818
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
1919
const [selectedTag, setSelectedTag] = useState<string | null>(null);
2020

21+
const allTags = useMemo(
22+
() => Array.from(new Set((notes ?? []).flatMap((n) => n.tags))).sort(),
23+
[notes],
24+
);
25+
2126
if (notes === undefined) {
2227
return <p className="p-4 text-neutral-500">Loading…</p>;
2328
}
@@ -26,7 +31,6 @@ export default function NotesView() {
2631
return <p className="p-4 text-neutral-500">No notes yet.</p>;
2732
}
2833

29-
const allTags = Array.from(new Set(notes.flatMap((n) => n.tags))).sort();
3034
const filteredNotes = selectedTag ? notes.filter((n) => n.tags.includes(selectedTag)) : notes;
3135

3236
return (

components/SearchView.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useLiveQuery } from 'dexie-react-hooks';
4-
import { useState, useEffect, useCallback } from 'react';
4+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
55
import { useSearchParams } from 'next/navigation';
66
import { db, type Note } from '@/lib/db';
77
import { relativeTime } from '@/lib/utils';
@@ -39,7 +39,10 @@ export default function SearchView() {
3939
setQuery(target);
4040
};
4141

42+
const initRunning = useRef(false);
4243
const initSemanticMode = useCallback(async () => {
44+
if (initRunning.current) return;
45+
initRunning.current = true;
4346
setModelStatus('loading');
4447
try {
4548
const { generateEmbedding, embedAllUnembedded } = await import('@/lib/embeddings');
@@ -55,6 +58,7 @@ export default function SearchView() {
5558
} catch {
5659
setModelStatus('error');
5760
setMode('keyword');
61+
initRunning.current = false;
5862
}
5963
}, []);
6064

@@ -86,10 +90,13 @@ export default function SearchView() {
8690
}
8791
};
8892

89-
const keywordResults =
90-
allNotes && query
91-
? allNotes.filter((n) => n.text.toLowerCase().includes(query.toLowerCase()))
92-
: [];
93+
const keywordResults = useMemo(
94+
() =>
95+
allNotes && query
96+
? allNotes.filter((n) => n.text.toLowerCase().includes(query.toLowerCase()))
97+
: [],
98+
[allNotes, query],
99+
);
93100

94101
const renderNote = (note: Note, score?: number) => (
95102
<li key={note.id} className="rounded-xl bg-neutral-900 p-4">

eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
1414
'out/**',
1515
'build/**',
1616
'next-env.d.ts',
17+
// Generated service worker bundle
18+
'public/sw.js',
1719
]),
1820
]);
1921

lib/db.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ export async function deleteNote(id: number): Promise<void> {
6969
// Embedding helpers
7070

7171
export async function putEmbedding(noteId: number, vector: number[]): Promise<void> {
72-
const existing = await db.embeddings.where('noteId').equals(noteId).first();
73-
if (existing) {
74-
await db.embeddings.update(existing.id, { vector });
75-
} else {
76-
await db.embeddings.add({ noteId, vector } as Embedding);
77-
}
72+
await db.transaction('rw', db.embeddings, async () => {
73+
const existing = await db.embeddings.where('noteId').equals(noteId).first();
74+
if (existing) {
75+
await db.embeddings.update(existing.id, { vector });
76+
} else {
77+
await db.embeddings.add({ noteId, vector } as Embedding);
78+
}
79+
});
7880
}
7981

8082
export async function getEmbedding(noteId: number): Promise<Embedding | undefined> {

lib/embeddings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export function cosineSimilarity(a: number[], b: number[]): number {
3434
export async function embedNote(noteId: number, text: string): Promise<void> {
3535
try {
3636
const vector = await generateEmbedding(text);
37+
const note = await db.notes.get(noteId);
38+
if (!note) return;
3739
await putEmbedding(noteId, vector);
3840
} catch {
3941
// Silent failure — same pattern as autoTagNote

0 commit comments

Comments
 (0)