diff --git a/web/src/components/MemoContent/TaskListItem.tsx b/web/src/components/MemoContent/TaskListItem.tsx index 94545ebb3..0f89f7d2a 100644 --- a/web/src/components/MemoContent/TaskListItem.tsx +++ b/web/src/components/MemoContent/TaskListItem.tsx @@ -61,7 +61,7 @@ export const TaskListItem: React.FC = ({ checked, node: _node name: memo.name, content: newContent, }, - updateMask: ["content"], + updateMask: ["content", "update_time"], }); }; diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 60c456593..33a0be5e2 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -32,6 +32,7 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [ const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const; const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const; const SPAN_CLASS_NAMES = ["mention", "tag"] as const; +const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const; export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src)); @@ -49,6 +50,7 @@ export const SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, img: [...(defaultSchema.attributes?.img || []), "height", "width"], + input: INPUT_ATTRIBUTES, code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]], span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]], iframe: [ diff --git a/web/src/components/MemoEditor/hooks/useAutoSave.ts b/web/src/components/MemoEditor/hooks/useAutoSave.ts index 3a4bf18ad..18eb903e7 100644 --- a/web/src/components/MemoEditor/hooks/useAutoSave.ts +++ b/web/src/components/MemoEditor/hooks/useAutoSave.ts @@ -1,11 +1,15 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { cacheService } from "../services"; export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => { const latestContentRef = useRef(content); + const discardedContentRef = useRef(undefined); useEffect(() => { latestContentRef.current = content; + if (discardedContentRef.current !== undefined && discardedContentRef.current !== content) { + discardedContentRef.current = undefined; + } }, [content]); useEffect(() => { @@ -20,6 +24,10 @@ export const useAutoSave = (content: string, username: string, cacheKey: string const key = cacheService.key(username, cacheKey); const flushDraft = () => { + if (discardedContentRef.current === latestContentRef.current) { + return; + } + cacheService.saveNow(key, latestContentRef.current); }; const handleVisibilityChange = () => { @@ -39,4 +47,12 @@ export const useAutoSave = (content: string, username: string, cacheKey: string document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [username, cacheKey, enabled]); + + const discardDraft = useCallback(() => { + const key = cacheService.key(username, cacheKey); + discardedContentRef.current = latestContentRef.current; + cacheService.clear(key); + }, [username, cacheKey]); + + return { discardDraft }; }; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 51457b5d4..853ce6fc4 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -22,19 +22,13 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de if (initializedRef.current) return; initializedRef.current = true; const key = cacheService.key(username, cacheKey); - const cachedContent = cacheService.load(key); if (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; - } + cacheService.clear(key); dispatch(actions.initMemo(initialState)); } else { + const cachedContent = cacheService.load(key); if (cachedContent) { dispatch(actions.updateContent(cachedContent)); } diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 746b7df23..248e85a42 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -23,7 +23,7 @@ import { import { FOCUS_MODE_STYLES } from "./constants"; import type { EditorRefActions } from "./Editor"; import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; -import { cacheService, errorService, memoService, transcriptionService, validationService } from "./services"; +import { errorService, memoService, transcriptionService, validationService } from "./services"; import { EditorProvider, useEditorContext } from "./state"; import type { MemoEditorProps } from "./types"; import type { LocalFile } from "./types/attachment"; @@ -69,9 +69,10 @@ const MemoEditorImpl: React.FC = ({ const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const isDraftCacheEnabled = !memo; // Auto-save content to localStorage - useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized); + const { discardDraft } = useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled); // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); @@ -229,8 +230,9 @@ const MemoEditorImpl: React.FC = ({ return; } - // Clear localStorage cache on successful save - cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey)); + // Clear localStorage cache on successful save and prevent the unmount + // flush from writing the just-saved content back as a stale draft. + discardDraft(); // Invalidate React Query cache to refresh memo lists across the app const invalidationPromises = [ diff --git a/web/src/components/MemoEditor/services/cacheService.ts b/web/src/components/MemoEditor/services/cacheService.ts index dde785cd2..07826f24e 100644 --- a/web/src/components/MemoEditor/services/cacheService.ts +++ b/web/src/components/MemoEditor/services/cacheService.ts @@ -1,6 +1,33 @@ export const CACHE_DEBOUNCE_DELAY = 500; const pendingSaves = new Map>(); +const STRUCTURED_CACHE_ENTRY_KIND = "memos.editor-cache"; +const STRUCTURED_CACHE_ENTRY_VERSION = 1; + +function deserializeContent(raw: string): string { + try { + const parsed = JSON.parse(raw) as { kind?: unknown; version?: unknown; content?: unknown }; + if ( + parsed.kind === STRUCTURED_CACHE_ENTRY_KIND && + parsed.version === STRUCTURED_CACHE_ENTRY_VERSION && + typeof parsed.content === "string" + ) { + return parsed.content; + } + } catch { + // Drafts have historically been stored as raw markdown strings. + } + + return raw; +} + +function writeEntry(key: string, content: string): void { + if (content.trim()) { + localStorage.setItem(key, content); + } else { + localStorage.removeItem(key); + } +} export const cacheService = { key: (username: string, cacheKey?: string): string => { @@ -16,11 +43,7 @@ export const cacheService = { const timeoutId = window.setTimeout(() => { pendingSaves.delete(key); - if (content.trim()) { - localStorage.setItem(key, content); - } else { - localStorage.removeItem(key); - } + writeEntry(key, content); }, CACHE_DEBOUNCE_DELAY); pendingSaves.set(key, timeoutId); @@ -33,15 +56,12 @@ export const cacheService = { pendingSaves.delete(key); } - if (content.trim()) { - localStorage.setItem(key, content); - } else { - localStorage.removeItem(key); - } + writeEntry(key, content); }, load(key: string): string { - return localStorage.getItem(key) || ""; + const raw = localStorage.getItem(key); + return raw ? deserializeContent(raw) : ""; }, clear(key: string): void { diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts index 458c5c6b6..963a9d15d 100644 --- a/web/src/hooks/useMemoQueries.ts +++ b/web/src/hooks/useMemoQueries.ts @@ -1,9 +1,10 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; +import type { InfiniteData } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { memoServiceClient } from "@/connect"; import { userKeys } from "@/hooks/useUserQueries"; -import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { ListMemosRequest, ListMemosResponse, Memo } from "@/types/proto/api/v1/memo_service_pb"; import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; // Query keys factory for consistent cache management @@ -16,6 +17,96 @@ export const memoKeys = { comments: (name: string) => [...memoKeys.all, "comments", name] as const, }; +type MemoPatch = Partial & Pick; +type MemoCollectionQueryData = ListMemosResponse | InfiniteData; + +function isMemoListResponse(data: unknown): data is ListMemosResponse { + return typeof data === "object" && data !== null && Array.isArray((data as { memos?: unknown }).memos); +} + +function isInfiniteMemoListData(data: unknown): data is InfiniteData { + return typeof data === "object" && data !== null && Array.isArray((data as { pages?: unknown }).pages); +} + +function patchMemoListResponse(response: ListMemosResponse, update: MemoPatch): ListMemosResponse { + let changed = false; + const memos = response.memos.map((memo) => { + if (memo.name !== update.name) { + return memo; + } + + changed = true; + return { ...memo, ...update }; + }); + + return changed ? { ...response, memos } : response; +} + +function patchMemoListQueryData(data: T | undefined, update: MemoPatch): T | undefined { + if (!data) { + return data; + } + + if (isMemoListResponse(data)) { + return patchMemoListResponse(data, update) as T; + } + + if (isInfiniteMemoListData(data)) { + let changed = false; + const pages = data.pages.map((page) => { + const patchedPage = patchMemoListResponse(page, update); + if (patchedPage !== page) { + changed = true; + } + return patchedPage; + }); + + return (changed ? { ...data, pages } : data) as T; + } + + return data; +} + +function findMemoInListResponse(response: ListMemosResponse, name: string): Memo | undefined { + return response.memos.find((memo) => memo.name === name); +} + +function findMemoInQueryData(data: unknown, name: string): Memo | undefined { + if (!data) { + return undefined; + } + + if (isMemoListResponse(data)) { + return findMemoInListResponse(data, name); + } + + if (isInfiniteMemoListData(data)) { + for (const page of data.pages) { + const memo = findMemoInListResponse(page, name); + if (memo) { + return memo; + } + } + } + + return undefined; +} + +function findMemoInCollectionQueries(queryClient: ReturnType, name: string): Memo | undefined { + for (const [, data] of queryClient.getQueriesData({ queryKey: memoKeys.all })) { + const memo = findMemoInQueryData(data, name); + if (memo) { + return memo; + } + } + + return undefined; +} + +function patchMemoInCollectionQueries(queryClient: ReturnType, update: MemoPatch) { + queryClient.setQueriesData({ queryKey: memoKeys.all }, (data) => patchMemoListQueryData(data, update)); +} + export function useMemos(request: Partial = {}) { return useQuery({ queryKey: memoKeys.list(request), @@ -94,15 +185,18 @@ export function useUpdateMemo() { } // Cancel outgoing refetches to prevent race conditions - await queryClient.cancelQueries({ queryKey: memoKeys.detail(update.name) }); + await queryClient.cancelQueries({ queryKey: memoKeys.all }); // Snapshot previous value for rollback on error - const previousMemo = queryClient.getQueryData(memoKeys.detail(update.name)); + const previousMemo = + queryClient.getQueryData(memoKeys.detail(update.name)) || findMemoInCollectionQueries(queryClient, update.name); + const memoPatch: MemoPatch = { ...update, name: update.name }; // Optimistically update the cache if (previousMemo) { - queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...update }); + queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...memoPatch }); } + patchMemoInCollectionQueries(queryClient, memoPatch); return { previousMemo }; }, @@ -110,11 +204,15 @@ export function useUpdateMemo() { // Rollback on error if (context?.previousMemo && update.name) { queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo); + patchMemoInCollectionQueries(queryClient, context.previousMemo); + } else { + queryClient.invalidateQueries({ queryKey: memoKeys.all }); } }, onSuccess: (updatedMemo) => { // Update cache with server response queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo); + patchMemoInCollectionQueries(queryClient, updatedMemo); // Invalidate lists to refresh queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); if (updatedMemo.parent) { diff --git a/web/tests/memo-content-security.test.tsx b/web/tests/memo-content-security.test.tsx index 13b9923a7..455ad2bab 100644 --- a/web/tests/memo-content-security.test.tsx +++ b/web/tests/memo-content-security.test.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { describe, expect, it } from "vitest"; import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants"; @@ -28,6 +29,13 @@ const renderMemoContent = (content: string): string => , ); +const renderGfmContent = (content: string): string => + renderToStaticMarkup( + + {content} + , + ); + describe("memo content sanitization", () => { it("strips user-controlled inline styles from raw HTML spans", () => { const html = renderMemoContent('overlay'); @@ -43,6 +51,15 @@ describe("memo content sanitization", () => { expect(html).toMatch(/class="katex"/); expect(html).toMatch(/class="katex-html"/); }); + + it("preserves checked state for GFM task list items", () => { + const html = renderGfmContent("- [x] Done\n- [ ] Todo"); + const inputs = html.match(/]+\/>/g) ?? []; + + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain('checked=""'); + expect(inputs[1]).not.toContain('checked=""'); + }); }); describe("trusted iframe providers", () => { diff --git a/web/tests/memo-editor-cache.test.ts b/web/tests/memo-editor-cache.test.ts new file mode 100644 index 000000000..83b8d7d7d --- /dev/null +++ b/web/tests/memo-editor-cache.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cacheService } from "@/components/MemoEditor/services/cacheService"; + +describe("memo editor cache", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + removeItem: vi.fn((key: string) => storage.delete(key)), + }); + cacheService.clearAll(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stores draft content", () => { + const key = cacheService.key("users/steven", "home-memo-editor"); + + cacheService.saveNow(key, "- [x] Draft task"); + + expect(cacheService.load(key)).toBe("- [x] Draft task"); + }); + + it("removes empty draft content instead of caching it", () => { + const key = cacheService.key("users/steven", "home-memo-editor"); + + cacheService.saveNow(key, ""); + + expect(cacheService.load(key)).toBe(""); + }); + + it("loads content from previously structured draft entries", () => { + const key = cacheService.key("users/steven", "home-memo-editor"); + localStorage.setItem(key, JSON.stringify({ kind: "memos.editor-cache", version: 1, content: "- [ ] migrated task" })); + + expect(cacheService.load(key)).toBe("- [ ] migrated task"); + }); + + it("keeps raw JSON markdown drafts intact", () => { + const key = cacheService.key("users/steven", "home-memo-editor"); + const jsonDraft = '{"content":"not a cache envelope"}'; + localStorage.setItem(key, jsonDraft); + + expect(cacheService.load(key)).toBe(jsonDraft); + }); + + it("keeps structured-looking drafts without a supported version intact", () => { + const key = cacheService.key("users/steven", "home-memo-editor"); + const jsonDraft = JSON.stringify({ kind: "memos.editor-cache", content: "not a supported envelope" }); + localStorage.setItem(key, jsonDraft); + + expect(cacheService.load(key)).toBe(jsonDraft); + }); +});