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 ...@@ -61,7 +61,7 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node
name: memo.name, name: memo.name,
content: newContent, content: newContent,
}, },
updateMask: ["content"], updateMask: ["content", "update_time"],
}); });
}; };
......
...@@ -32,6 +32,7 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [ ...@@ -32,6 +32,7 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [
const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const; const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const;
const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const; const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const;
const SPAN_CLASS_NAMES = ["mention", "tag"] 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)); export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src));
...@@ -49,6 +50,7 @@ export const SANITIZE_SCHEMA = { ...@@ -49,6 +50,7 @@ export const SANITIZE_SCHEMA = {
attributes: { attributes: {
...defaultSchema.attributes, ...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "height", "width"], img: [...(defaultSchema.attributes?.img || []), "height", "width"],
input: INPUT_ATTRIBUTES,
code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]], code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]],
span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]], span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]],
iframe: [ iframe: [
......
import { useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { cacheService } from "../services"; import { cacheService } from "../services";
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => { export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => {
const latestContentRef = useRef(content); const latestContentRef = useRef(content);
const discardedContentRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
latestContentRef.current = content; latestContentRef.current = content;
if (discardedContentRef.current !== undefined && discardedContentRef.current !== content) {
discardedContentRef.current = undefined;
}
}, [content]); }, [content]);
useEffect(() => { useEffect(() => {
...@@ -20,6 +24,10 @@ export const useAutoSave = (content: string, username: string, cacheKey: string ...@@ -20,6 +24,10 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
const key = cacheService.key(username, cacheKey); const key = cacheService.key(username, cacheKey);
const flushDraft = () => { const flushDraft = () => {
if (discardedContentRef.current === latestContentRef.current) {
return;
}
cacheService.saveNow(key, latestContentRef.current); cacheService.saveNow(key, latestContentRef.current);
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
...@@ -39,4 +47,12 @@ export const useAutoSave = (content: string, username: string, cacheKey: string ...@@ -39,4 +47,12 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [username, cacheKey, enabled]); }, [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 ...@@ -22,19 +22,13 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de
if (initializedRef.current) return; if (initializedRef.current) return;
initializedRef.current = true; initializedRef.current = true;
const key = cacheService.key(username, cacheKey); const key = cacheService.key(username, cacheKey);
const cachedContent = cacheService.load(key);
if (memo) { if (memo) {
const initialState = memoService.fromMemo(memo); const initialState = memoService.fromMemo(memo);
// Prefer cached draft over the saved memo content when the user had unsaved cacheService.clear(key);
// 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)); dispatch(actions.initMemo(initialState));
} else { } else {
const cachedContent = cacheService.load(key);
if (cachedContent) { if (cachedContent) {
dispatch(actions.updateContent(cachedContent)); dispatch(actions.updateContent(cachedContent));
} }
......
...@@ -23,7 +23,7 @@ import { ...@@ -23,7 +23,7 @@ import {
import { FOCUS_MODE_STYLES } from "./constants"; import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor"; import type { EditorRefActions } from "./Editor";
import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; 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 { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types"; import type { MemoEditorProps } from "./types";
import type { LocalFile } from "./types/attachment"; import type { LocalFile } from "./types/attachment";
...@@ -69,9 +69,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -69,9 +69,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
const isDraftCacheEnabled = !memo;
// Auto-save content to localStorage // 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 // Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode); useFocusMode(state.ui.isFocusMode);
...@@ -229,8 +230,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -229,8 +230,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
return; return;
} }
// Clear localStorage cache on successful save // Clear localStorage cache on successful save and prevent the unmount
cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey)); // flush from writing the just-saved content back as a stale draft.
discardDraft();
// Invalidate React Query cache to refresh memo lists across the app // Invalidate React Query cache to refresh memo lists across the app
const invalidationPromises = [ const invalidationPromises = [
......
export const CACHE_DEBOUNCE_DELAY = 500; export const CACHE_DEBOUNCE_DELAY = 500;
const pendingSaves = new Map<string, ReturnType<typeof window.setTimeout>>(); 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 = { export const cacheService = {
key: (username: string, cacheKey?: string): string => { key: (username: string, cacheKey?: string): string => {
...@@ -16,11 +43,7 @@ export const cacheService = { ...@@ -16,11 +43,7 @@ export const cacheService = {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
pendingSaves.delete(key); pendingSaves.delete(key);
if (content.trim()) { writeEntry(key, content);
localStorage.setItem(key, content);
} else {
localStorage.removeItem(key);
}
}, CACHE_DEBOUNCE_DELAY); }, CACHE_DEBOUNCE_DELAY);
pendingSaves.set(key, timeoutId); pendingSaves.set(key, timeoutId);
...@@ -33,15 +56,12 @@ export const cacheService = { ...@@ -33,15 +56,12 @@ export const cacheService = {
pendingSaves.delete(key); pendingSaves.delete(key);
} }
if (content.trim()) { writeEntry(key, content);
localStorage.setItem(key, content);
} else {
localStorage.removeItem(key);
}
}, },
load(key: string): string { load(key: string): string {
return localStorage.getItem(key) || ""; const raw = localStorage.getItem(key);
return raw ? deserializeContent(raw) : "";
}, },
clear(key: string): void { clear(key: string): void {
......
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import type { InfiniteData } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { memoServiceClient } from "@/connect"; import { memoServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries"; 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"; import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
// Query keys factory for consistent cache management // Query keys factory for consistent cache management
...@@ -16,6 +17,96 @@ export const memoKeys = { ...@@ -16,6 +17,96 @@ export const memoKeys = {
comments: (name: string) => [...memoKeys.all, "comments", name] as const, 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> = {}) { export function useMemos(request: Partial<ListMemosRequest> = {}) {
return useQuery({ return useQuery({
queryKey: memoKeys.list(request), queryKey: memoKeys.list(request),
...@@ -94,15 +185,18 @@ export function useUpdateMemo() { ...@@ -94,15 +185,18 @@ export function useUpdateMemo() {
} }
// Cancel outgoing refetches to prevent race conditions // 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 // 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 // Optimistically update the cache
if (previousMemo) { if (previousMemo) {
queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...update }); queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...memoPatch });
} }
patchMemoInCollectionQueries(queryClient, memoPatch);
return { previousMemo }; return { previousMemo };
}, },
...@@ -110,11 +204,15 @@ export function useUpdateMemo() { ...@@ -110,11 +204,15 @@ export function useUpdateMemo() {
// Rollback on error // Rollback on error
if (context?.previousMemo && update.name) { if (context?.previousMemo && update.name) {
queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo); queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo);
patchMemoInCollectionQueries(queryClient, context.previousMemo);
} else {
queryClient.invalidateQueries({ queryKey: memoKeys.all });
} }
}, },
onSuccess: (updatedMemo) => { onSuccess: (updatedMemo) => {
// Update cache with server response // Update cache with server response
queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo); queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo);
patchMemoInCollectionQueries(queryClient, updatedMemo);
// Invalidate lists to refresh // Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
if (updatedMemo.parent) { if (updatedMemo.parent) {
......
...@@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown"; ...@@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants"; import { SANITIZE_SCHEMA, isTrustedIframeSrc } from "@/components/MemoContent/constants";
...@@ -28,6 +29,13 @@ const renderMemoContent = (content: string): string => ...@@ -28,6 +29,13 @@ const renderMemoContent = (content: string): string =>
</ReactMarkdown>, </ReactMarkdown>,
); );
const renderGfmContent = (content: string): string =>
renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, SANITIZE_SCHEMA]]}>
{content}
</ReactMarkdown>,
);
describe("memo content sanitization", () => { describe("memo content sanitization", () => {
it("strips user-controlled inline styles from raw HTML spans", () => { it("strips user-controlled inline styles from raw HTML spans", () => {
const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>'); const html = renderMemoContent('<span style="position:fixed;inset:0;z-index:99999">overlay</span>');
...@@ -43,6 +51,15 @@ describe("memo content sanitization", () => { ...@@ -43,6 +51,15 @@ describe("memo content sanitization", () => {
expect(html).toMatch(/class="katex"/); expect(html).toMatch(/class="katex"/);
expect(html).toMatch(/class="katex-html"/); 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", () => { 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