fix(web): drop previous note images and bodies when switching

Release decoded editor media before replacing the TipTap document, and evict
idle memo-detail queries so visiting image-heavy notes does not keep their
bitmaps and JSON in the renderer.
This commit is contained in:
tianma-if committed 2026-09-18 16:40:42 +08:00
1 parent 92cfe6939b
commit 2d3f37d180
10 files changed
+150 -1

No files matched your search

+4
View File
@@ -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));
}
@@ -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");
+4
View File
@@ -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) => {
@@ -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<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [previewWidth, setPreviewWidth] = useState<number | null>(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 = ({
}}
>
<img
ref={imageRef}
src={src}
alt={alt}
title={title || undefined}
@@ -50,6 +50,7 @@ describe("editor document reset", () => {
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");
@@ -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),
@@ -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;
}
});
});
+42
View File
@@ -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<Element>;
};
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 };
};
@@ -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"]]);
});
});
+9
View File
@@ -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<string>) => {
const summaries = new Map<string, MemoSummary>();