fix: preserve draft content when tab is suspended or editor remounts
Gate auto-save on initialization to prevent empty content from overwriting cached drafts on remount. Flush drafts synchronously on visibilitychange/ pagehide so Chromium tab suspension cannot drop pending debounced saves. Restore cached draft over saved memo content when they differ (inline editing).
This commit is contained in:
parent
938c405b0f
commit
9ca71229a6
4 changed files with 69 additions and 8 deletions
|
|
@ -1,9 +1,42 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cacheService } from "../services";
|
||||
|
||||
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => {
|
||||
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => {
|
||||
const latestContentRef = useRef(content);
|
||||
|
||||
useEffect(() => {
|
||||
latestContentRef.current = content;
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const key = cacheService.key(username, cacheKey);
|
||||
cacheService.save(key, content);
|
||||
}, [content, username, cacheKey]);
|
||||
}, [content, username, cacheKey, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const key = cacheService.key(username, cacheKey);
|
||||
const flushDraft = () => {
|
||||
cacheService.saveNow(key, latestContentRef.current);
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flushDraft();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", flushDraft);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
// Flush on unmount (e.g. editor closes) to ensure the draft is persisted
|
||||
// before the component is torn down — distinct from the visibility flush above.
|
||||
flushDraft();
|
||||
window.removeEventListener("pagehide", flushDraft);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [username, cacheKey, enabled]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import { cacheService, memoService } from "../services";
|
||||
|
|
@ -16,15 +16,25 @@ interface UseMemoInitOptions {
|
|||
export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => {
|
||||
const { actions, dispatch } = useEditorContext();
|
||||
const initializedRef = useRef(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
const key = cacheService.key(username, cacheKey);
|
||||
const cachedContent = cacheService.load(key);
|
||||
|
||||
if (memo) {
|
||||
dispatch(actions.initMemo(memoService.fromMemo(memo)));
|
||||
const initialState = memoService.fromMemo(memo);
|
||||
// Prefer cached draft over the saved memo content when the user had unsaved
|
||||
// changes (e.g. tab was suspended mid-edit). Uses strict string comparison
|
||||
// against memo.content — both values come from the same proto serialization
|
||||
// path, so format is consistent and whitespace differences are intentional.
|
||||
if (cachedContent.trim() && cachedContent !== memo.content) {
|
||||
initialState.content = cachedContent;
|
||||
}
|
||||
dispatch(actions.initMemo(initialState));
|
||||
} else {
|
||||
const cachedContent = cacheService.load(cacheService.key(username, cacheKey));
|
||||
if (cachedContent) {
|
||||
dispatch(actions.updateContent(cachedContent));
|
||||
}
|
||||
|
|
@ -36,5 +46,9 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de
|
|||
if (autoFocus) {
|
||||
setTimeout(() => editorRef.current?.focus(), 100);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
}, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]);
|
||||
|
||||
return { isInitialized };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
// Get default visibility from user settings
|
||||
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
|
||||
|
||||
useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
|
||||
const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
|
||||
|
||||
// Auto-save content to localStorage
|
||||
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
|
||||
useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized);
|
||||
|
||||
// Focus mode management with body scroll lock
|
||||
useFocusMode(state.ui.isFocusMode);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,20 @@ export const cacheService = {
|
|||
pendingSaves.set(key, timeoutId);
|
||||
},
|
||||
|
||||
saveNow: (key: string, content: string) => {
|
||||
const pendingSave = pendingSaves.get(key);
|
||||
if (pendingSave) {
|
||||
window.clearTimeout(pendingSave);
|
||||
pendingSaves.delete(key);
|
||||
}
|
||||
|
||||
if (content.trim()) {
|
||||
localStorage.setItem(key, content);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
},
|
||||
|
||||
load(key: string): string {
|
||||
return localStorage.getItem(key) || "";
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue