diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 75502e50..8119fb6a 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -256,6 +256,7 @@ import { isCreatedMemoEditorFocused, isEditorReady, MemoSaveRequestError, + releaseEditorMedia, resetEditorDocument, shouldRetryCreatedMemoFocus, MOBILE_DRAFT_PERSIST_DELAY_MS, @@ -2071,6 +2072,7 @@ const RichEditorPane = ({ setSaveState("idle"); setStorageSaveError(false); if (isEditorReady(currentEditor)) { + releaseEditorMedia(currentEditor); currentEditor.commands.clearContent(); } return; @@ -2085,6 +2087,7 @@ const RichEditorPane = ({ clearMarkdownSnapshot(); const immediateDraft = resolveEditorDraftState({ memo, draft: null, queuedUpdate: null }); editingMemoIdRef.current = memo.id; + setImagePreview(null); setHasUnsavedChanges(false); setSaveState("idle"); setSaveConflictInfo(null); @@ -2271,6 +2274,7 @@ const RichEditorPane = ({ } } catch (err) { console.error("Failed to set TipTap contentJson, falling back to markdownToDoc:", err); + releaseEditorMedia(currentEditor); currentEditor.commands.setContent(markdownToDoc(nextMarkdown)); } diff --git a/apps/web/src/components/WorkspaceApp.create-memo.test.mjs b/apps/web/src/components/WorkspaceApp.create-memo.test.mjs index 212cfec2..b01f6bab 100644 --- a/apps/web/src/components/WorkspaceApp.create-memo.test.mjs +++ b/apps/web/src/components/WorkspaceApp.create-memo.test.mjs @@ -37,6 +37,10 @@ describe("desktop create-note wiring", () => { expect(workspaceSource).toContain("queryClient.prefetchQuery"); }); + test("evicts idle memo bodies when switching notes", () => { + expect(workspaceSource).toContain("evictIdleMemoDetails(queryClient, detailMemoId)"); + }); + test("restores the last desktop memo after a renderer hibernate reload", () => { expect(workspaceSource).toContain("readDesktopWorkspaceRestoreState"); expect(workspaceSource).toContain("writeDesktopWorkspaceRestoreState"); diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 94d6e44f..e6d7d2ea 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -78,6 +78,7 @@ import { useBrowserBackLayer } from "@/lib/app-hooks"; import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache"; import { cacheMemoDetail, + evictIdleMemoDetails, clearTrashMemoLists, collectMemoSummariesFromCache, decrementNotebookMemoCounts, @@ -1146,6 +1147,9 @@ export const WorkspaceApp = ({ queryFn: () => repository.getMemo(memoId, memoView === "trash"), }); }, [memoView, queryClient, repository]); + useEffect(() => { + evictIdleMemoDetails(queryClient, detailMemoId); + }, [detailMemoId, queryClient]); useEffect(() => { const handleMemoDetailRefreshed = (event: Event) => { diff --git a/apps/web/src/components/editor/ResizableImage.tsx b/apps/web/src/components/editor/ResizableImage.tsx index 55c7c274..aaf36120 100644 --- a/apps/web/src/components/editor/ResizableImage.tsx +++ b/apps/web/src/components/editor/ResizableImage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react"; import { mergeAttributes } from "@tiptap/core"; import Image from "@tiptap/extension-image"; import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react"; @@ -45,6 +45,7 @@ const ResizableImageNodeView = ({ }: NodeViewProps) => { const { t } = useTranslation(); const wrapperRef = useRef(null); + const imageRef = useRef(null); const [previewWidth, setPreviewWidth] = useState(null); const nodeWidth = parseImageWidth(node.attrs.width) ?? DEFAULT_IMAGE_WIDTH_PERCENT; const width = previewWidth ?? nodeWidth; @@ -98,6 +99,17 @@ const ResizableImageNodeView = ({ const canGroupAdjacentImages = selected && Boolean(getAdjacentImageGroup()); + useEffect(() => () => { + const image = imageRef.current; + if (!image) return; + const src = image.getAttribute("src") || image.src; + image.removeAttribute("src"); + image.src = ""; + if (src.startsWith("blob:")) { + URL.revokeObjectURL(src); + } + }, []); + const groupAdjacentImages = useCallback(() => { const group = getAdjacentImageGroup(); const galleryType = editor.state.schema.nodes[IMAGE_GALLERY_NODE_TYPE]; @@ -220,6 +232,7 @@ const ResizableImageNodeView = ({ }} > {alt} { const source = readFileSync(new URL("./editor-pane-helpers.ts", import.meta.url), "utf8"); expect(source).toContain("export const resetEditorDocument"); + expect(source).toContain("releaseEditorMedia(editor)"); expect(source).toContain("editor.view.updateState(EditorState.create({"); expect(source).toContain("doc: editor.schema.nodeFromJSON(content)"); expect(source).toContain("plugins: editor.state.plugins"); diff --git a/apps/web/src/components/editor/editor-pane-helpers.ts b/apps/web/src/components/editor/editor-pane-helpers.ts index b4b8b312..d60c7866 100644 --- a/apps/web/src/components/editor/editor-pane-helpers.ts +++ b/apps/web/src/components/editor/editor-pane-helpers.ts @@ -1,6 +1,7 @@ import { EditorState } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; import type { MemoDetail, MemoEditSession, TiptapDoc } from "@edgeever/shared"; +import { releaseHtmlMediaSources } from "@/lib/editor-media-release"; import { isDesktopResourceRuntime } from "@/lib/desktop-resources"; import { isBrowserOffline } from "@/lib/network-status"; import { isLocalMemoId } from "@/lib/local-mirror"; @@ -78,11 +79,17 @@ export type MobilePlainTextElement = HTMLTextAreaElement | HTMLDivElement; export const isEditorReady = (editor: Editor | null | undefined): editor is Editor => Boolean(editor && !editor.isDestroyed && (editor as { extensionManager?: unknown }).extensionManager); +export const releaseEditorMedia = (editor: Editor) => { + releaseHtmlMediaSources(editor.view.dom); +}; + /** * Replace the document with a fresh EditorState so undo/redo cannot leak * across memos. Cheaper than destroying the TipTap view on every switch. + * Releases decoded images from the previous document first. */ export const resetEditorDocument = (editor: Editor, content: TiptapDoc) => { + releaseEditorMedia(editor); editor.view.updateState(EditorState.create({ schema: editor.schema, doc: editor.schema.nodeFromJSON(content), diff --git a/apps/web/src/lib/editor-media-release.test.mjs b/apps/web/src/lib/editor-media-release.test.mjs new file mode 100644 index 00000000..fb1e73a2 --- /dev/null +++ b/apps/web/src/lib/editor-media-release.test.mjs @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { isRevocableObjectUrl, releaseHtmlMediaSources } from "./editor-media-release.ts"; + +describe("editor media release", () => { + test("recognizes blob object URLs", () => { + expect(isRevocableObjectUrl("blob:http://127.0.0.1/abc")).toBe(true); + expect(isRevocableObjectUrl("edgeever-resource://resource/img_1")).toBe(false); + }); + + test("blanks image sources and revokes blob URLs", () => { + const revoked = []; + const originalRevoke = URL.revokeObjectURL; + URL.revokeObjectURL = (url) => { + revoked.push(url); + }; + + const blobSrc = "blob:http://edgeever.local/1"; + const img = { + src: blobSrc, + removeAttribute(name) { + if (name === "src") this.src = ""; + }, + }; + const canvas = { width: 640, height: 480 }; + const root = { + querySelectorAll(selector) { + if (selector === "img") return [img]; + if (selector === "canvas") return [canvas]; + return []; + }, + }; + + try { + expect(releaseHtmlMediaSources(root)).toEqual({ images: 1, revoked: 1 }); + expect(img.src).toBe(""); + expect(canvas).toEqual({ width: 0, height: 0 }); + expect(revoked).toEqual([blobSrc]); + } finally { + URL.revokeObjectURL = originalRevoke; + } + }); +}); diff --git a/apps/web/src/lib/editor-media-release.ts b/apps/web/src/lib/editor-media-release.ts new file mode 100644 index 00000000..94378dd4 --- /dev/null +++ b/apps/web/src/lib/editor-media-release.ts @@ -0,0 +1,42 @@ +export const isRevocableObjectUrl = (value: string) => value.startsWith("blob:"); + +const blankMediaElement = (element: { src?: string; removeAttribute?: (name: string) => void }) => { + const src = typeof element.src === "string" ? element.src : ""; + element.removeAttribute?.("src"); + if ("src" in element) element.src = ""; + return src; +}; + +type MediaRoot = { + querySelectorAll: (selector: string) => Iterable; +}; + +export const releaseHtmlMediaSources = (root: MediaRoot | null | undefined) => { + if (!root) { + return { images: 0, revoked: 0 }; + } + + let images = 0; + let revoked = 0; + + for (const node of root.querySelectorAll("img")) { + images += 1; + const src = blankMediaElement(node as HTMLImageElement); + if (isRevocableObjectUrl(src)) { + URL.revokeObjectURL(src); + revoked += 1; + } + } + + for (const node of root.querySelectorAll("iframe, embed, object, video, audio, source")) { + blankMediaElement(node as HTMLImageElement); + } + + for (const node of root.querySelectorAll("canvas")) { + const canvas = node as HTMLCanvasElement; + canvas.width = 0; + canvas.height = 0; + } + + return { images, revoked }; +}; diff --git a/apps/web/src/lib/workspace-memo-cache.test.mjs b/apps/web/src/lib/workspace-memo-cache.test.mjs new file mode 100644 index 00000000..5a4b2fdb --- /dev/null +++ b/apps/web/src/lib/workspace-memo-cache.test.mjs @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { evictIdleMemoDetails } from "./workspace-memo-cache.ts"; + +describe("idle memo detail eviction", () => { + test("drops cached memo bodies except the open note", () => { + const removed = []; + const queryClient = { + removeQueries({ predicate }) { + for (const query of [ + { queryKey: ["memo", "memo_old", "notebook"] }, + { queryKey: ["memo", "memo_open", "notebook"] }, + { queryKey: ["memos", "notebook"] }, + { queryKey: ["memo-link-search", "q"] }, + ]) { + if (predicate(query)) removed.push(query.queryKey); + } + }, + }; + + evictIdleMemoDetails(queryClient, "memo_open"); + expect(removed).toEqual([["memo", "memo_old", "notebook"]]); + }); +}); diff --git a/apps/web/src/lib/workspace-memo-cache.ts b/apps/web/src/lib/workspace-memo-cache.ts index f70e3afd..f37ec192 100644 --- a/apps/web/src/lib/workspace-memo-cache.ts +++ b/apps/web/src/lib/workspace-memo-cache.ts @@ -37,6 +37,15 @@ export const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view queryClient.setQueryData(memoDetailQueryKey(memo.id, view), { memo }); }; +export const evictIdleMemoDetails = (queryClient: QueryClient, keepMemoId: string | null) => { + queryClient.removeQueries({ + predicate: (query) => { + const [kind, memoId] = query.queryKey; + return kind === "memo" && typeof memoId === "string" && memoId !== keepMemoId; + }, + }); +}; + export const collectMemoSummariesFromCache = (queryClient: QueryClient, memoIds: Set) => { const summaries = new Map();