fix(web): preserve task checkbox state (#5867)
This commit is contained in:
@@ -61,7 +61,7 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
updateMask: ["content"],
|
||||
updateMask: ["content", "update_time"],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<string | undefined>(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 };
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<MemoEditorProps> = ({
|
||||
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<MemoEditorProps> = ({
|
||||
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 = [
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
export const CACHE_DEBOUNCE_DELAY = 500;
|
||||
|
||||
const pendingSaves = new Map<string, ReturnType<typeof window.setTimeout>>();
|
||||
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 {
|
||||
|
||||
@@ -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<Memo> & Pick<Memo, "name">;
|
||||
type MemoCollectionQueryData = ListMemosResponse | InfiniteData<ListMemosResponse>;
|
||||
|
||||
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<ListMemosResponse> {
|
||||
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<T>(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<typeof useQueryClient>, name: string): Memo | undefined {
|
||||
for (const [, data] of queryClient.getQueriesData<unknown>({ queryKey: memoKeys.all })) {
|
||||
const memo = findMemoInQueryData(data, name);
|
||||
if (memo) {
|
||||
return memo;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function patchMemoInCollectionQueries(queryClient: ReturnType<typeof useQueryClient>, update: MemoPatch) {
|
||||
queryClient.setQueriesData<MemoCollectionQueryData>({ queryKey: memoKeys.all }, (data) => patchMemoListQueryData(data, update));
|
||||
}
|
||||
|
||||
export function useMemos(request: Partial<ListMemosRequest> = {}) {
|
||||
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<Memo>(memoKeys.detail(update.name));
|
||||
const previousMemo =
|
||||
queryClient.getQueryData<Memo>(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) {
|
||||
|
||||
@@ -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 =>
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
const renderGfmContent = (content: string): string =>
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, SANITIZE_SCHEMA]]}>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
describe("memo content sanitization", () => {
|
||||
it("strips user-controlled inline styles from raw HTML spans", () => {
|
||||
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
|
||||
@@ -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(/<input[^>]+\/>/g) ?? [];
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toContain('checked=""');
|
||||
expect(inputs[1]).not.toContain('checked=""');
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted iframe providers", () => {
|
||||
|
||||
@@ -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<string, string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user