Unverified Commit b5863d76 authored by boojack's avatar boojack Committed by GitHub

fix(web): preserve task checkbox state (#5867)

parent d98f6659
......@@ -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: [
......
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 = [
......
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 {
......
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", () => {
......
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);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment