Compare commits

...

2 Commits

Author SHA1 Message Date
dayuan.jiang
f19dc919e1 fix: prevent autosave from overwriting pending IDB restore 2026-01-27 13:43:25 +09:00
dayuan.jiang
8d92474f73 fix: recover IDB on closing and restore diagram 2026-01-27 12:07:35 +09:00
3 changed files with 99 additions and 40 deletions

View File

@@ -581,7 +581,7 @@ export default function ChatPanel({
try { try {
const currentSession = sessionManager.currentSession const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) { if (currentSession) {
// Restore from session manager (IndexedDB) // Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(currentSession) syncUIWithSession(currentSession)
@@ -618,7 +618,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session // Sync UI with new session
if (newSession && newSession.messages.length > 0) { if (newSession) {
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(newSession) syncUIWithSession(newSession)
} else if (!newSession) { } else if (!newSession) {

View File

@@ -89,6 +89,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
) { ) {
lastRestoredXmlRef.current = chartXML lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML }) drawioRef.current.load({ xml: chartXML })
} else if (!isRealDiagram(chartXML)) {
// Reset when diagram is cleared so a future restore can re-load the same XML.
lastRestoredXmlRef.current = ""
} }
}, [isDrawioReady, chartXML]) }, [isDrawioReady, chartXML])
@@ -265,6 +268,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const handleDiagramAutoSave = (data: { xml?: string }) => { const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
setChartXML(data.xml) setChartXML(data.xml)
} }

View File

@@ -47,6 +47,33 @@ interface ChatSessionDB extends DBSchema {
// Database singleton // Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
@@ -60,7 +87,23 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
} }
// Future migrations: if (oldVersion < 2) { ... } // Future migrations: if (oldVersion < 2) { ... }
}, },
terminated() {
resetDBPromise()
},
}) })
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
} }
return dbPromise return dbPromise
} }
@@ -92,27 +135,29 @@ export async function isIndexedDBUsable(): Promise<boolean> {
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []
try { try {
const db = await getDB() return await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readonly") const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = [] const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML) // Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) { while (cursor) {
const s = cursor.value const s = cursor.value
metadata.push({ metadata.push({
id: s.id, id: s.id,
title: s.title, title: s.title,
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
messageCount: s.messages.length, messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0, hasDiagram:
thumbnailDataUrl: s.thumbnailDataUrl, !!s.diagramXml && s.diagramXml.trim().length > 0,
}) thumbnailDataUrl: s.thumbnailDataUrl,
cursor = await cursor.continue() })
} cursor = await cursor.continue()
return metadata }
return metadata
})
} catch (error) { } catch (error) {
console.error("Failed to get session metadata:", error) console.error("Failed to get session metadata:", error)
return [] return []
@@ -122,8 +167,9 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> { export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null if (!isIndexedDBAvailable()) return null
try { try {
const db = await getDB() return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null return (await db.get(STORE_NAME, id)) || null
})
} catch (error) { } catch (error) {
console.error("Failed to get session:", error) console.error("Failed to get session:", error)
return null return null
@@ -133,8 +179,9 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
const db = await getDB() await withDB(async (db) => {
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (error) { } catch (error) {
// Handle quota exceeded // Handle quota exceeded
@@ -146,8 +193,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession() await deleteOldestSession()
// Retry once // Retry once
try { try {
const db = await getDB() await withDB(async (db) => {
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (retryError) { } catch (retryError) {
console.error( console.error(
@@ -166,8 +214,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> { export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
const db = await getDB() await withDB(async (db) => {
await db.delete(STORE_NAME, id) await db.delete(STORE_NAME, id)
})
} catch (error) { } catch (error) {
console.error("Failed to delete session:", error) console.error("Failed to delete session:", error)
} }
@@ -176,8 +225,9 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> { export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0 if (!isIndexedDBAvailable()) return 0
try { try {
const db = await getDB() return await withDB(async (db) => {
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
})
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return 0 return 0
@@ -187,14 +237,15 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> { export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
const db = await getDB() await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readwrite") const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const cursor = await index.openCursor() const cursor = await index.openCursor()
if (cursor) { if (cursor) {
await cursor.delete() await cursor.delete()
} }
await tx.done await tx.done
})
} catch (error) { } catch (error) {
console.error("Failed to delete oldest session:", error) console.error("Failed to delete oldest session:", error)
} }