Skip to content

Commit 81ec5fc

Browse files
committed
fix: debounced snapshot save to prevent file loss on refresh
The snapshot was taken 50ms after the last message change, but file actions were still processing in the async execution queue and 100ms watcher buffer. This caused incomplete file sets in the snapshot, leading to missing files after page refresh. Added a debounced file-change subscriber (500ms) that re-saves the snapshot after all file writes settle. This ensures the snapshot always contains the complete set of files. Root cause: snapshot timing race between the 50ms message sampler and the async file write pipeline (queue editor WebContainer 100ms watcher files store). All 452 tests passing.
1 parent 0abe9a4 commit 81ec5fc

1 file changed

Lines changed: 51 additions & 1 deletion

File tree

app/lib/persistence/useChatHistory.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
2-
import { useState, useEffect, useCallback } from 'react';
2+
import { useState, useEffect, useCallback, useRef } from 'react';
33
import { atom } from 'nanostores';
44
import { type JSONValue, type Message } from 'ai';
55
import { toast } from 'react-toastify';
@@ -51,6 +51,9 @@ export function useChatHistory() {
5151
const [ready, setReady] = useState<boolean>(false);
5252
const [urlId, setUrlId] = useState<string | undefined>();
5353

54+
// Track last snapshot parameters so debounced file-change saves use the same message ID
55+
const lastSnapshotParamsRef = useRef<{ chatIdx: string; chatSummary?: string } | null>(null);
56+
5457
useEffect(() => {
5558
if (!db) {
5659
setReady(true);
@@ -159,6 +162,50 @@ export function useChatHistory() {
159162
[db],
160163
);
161164

165+
/*
166+
* Debounced file-change subscriber: re-saves the snapshot after file writes settle.
167+
* The normal snapshot fires 50ms after the last message change, but file actions
168+
* may still be in the async execution queue or watcher buffer at that point.
169+
* This subscriber ensures a final snapshot is taken once all files are written.
170+
*/
171+
useEffect(() => {
172+
if (!db) {
173+
return undefined;
174+
}
175+
176+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
177+
178+
const unsubscribe = workbenchStore.files.subscribe(() => {
179+
const id = chatId.get();
180+
const params = lastSnapshotParamsRef.current;
181+
182+
if (!id || !params) {
183+
return;
184+
}
185+
186+
if (debounceTimer) {
187+
clearTimeout(debounceTimer);
188+
}
189+
190+
debounceTimer = setTimeout(() => {
191+
const files = workbenchStore.files.get();
192+
193+
if (Object.keys(files).length > 0) {
194+
logger.debug('Debounced file-change snapshot save');
195+
takeSnapshot(params.chatIdx, files, undefined, params.chatSummary);
196+
}
197+
}, 500);
198+
});
199+
200+
return () => {
201+
unsubscribe();
202+
203+
if (debounceTimer) {
204+
clearTimeout(debounceTimer);
205+
}
206+
};
207+
}, [db, takeSnapshot]);
208+
162209
const restoreSnapshot = useCallback(async (id: string, snapshot?: Snapshot) => {
163210
const container = await webcontainer;
164211

@@ -254,6 +301,9 @@ export function useChatHistory() {
254301
}
255302
}
256303

304+
// Save params so debounced file-change subscriber can re-save with updated files
305+
lastSnapshotParamsRef.current = { chatIdx: messages[messages.length - 1].id, chatSummary };
306+
257307
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
258308

259309
if (!description.get() && firstArtifact?.title) {

0 commit comments

Comments
 (0)