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:
1 parent
92cfe6939b
commit
2d3f37d180
10 files changed
+150
-1
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in new issue
Block a user