Fix markdown editor typing lag from per-keystroke re-renders

Every keystroke in MarkdownViewer updated changedValue state, which was
fed back into MDXEditor's markdown prop and re-rendered the editor.
RealmWithPlugins runs plugin.update() for every plugin on every render
(dep-less useEffect), so each keystroke re-ran all ~15 plugin updates
including the toolbar tree — the dominant lag source. The markdown prop
is also only the editor's *initial* value, so feeding edits back was
semantically wrong.

- MarkdownEditor is wrapped in React.memo, its plugins array is
  useMemo'd, and the translation callback is useCallback'd.
- MarkdownViewer tracks edits in a ref (read only at save) instead of
  state, passes the immutable loaded content as value, and hands the
  stable onChange callback directly — zero React re-renders per
  keystroke.

Fixes #199 (upstream cloudreve/cloudreve#3584).

Authored By: TDvorak <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 49f556dc57
commit 135d108bef

@ -205,7 +205,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints
- Fix upstream bug backlog by impact: ~~#3574 OOM~~ (done — paged tree walk + batched delete), ~~#3118/#3005 WebDAV large-file~~ (done — Content-Range assembly into one session; non-local policies get honest 501; single-PUT giant-file 500s are proxy/client timeouts, not fixable server-side), ~~#3375 SMTP auth discovery~~ (done — `smtp_auth` setting)
- #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before.
- [ ] #199 (upstream #3584) — markdown editor lag: profile the MDX editor path; likely re-render-per-keystroke, evaluate debounce/virtualization or lighter editor before swapping libraries
- [x] #199 (upstream #3584) — markdown editor lag: root cause was per-keystroke React re-renders (changedValue state fed back into the editor's initial-markdown prop) re-running every plugin's `update()` hook (RealmWithPlugins has a dep-less effect). `MarkdownEditor` is now `memo`'d with a `useMemo`'d plugins array + stable `translation`; `MarkdownViewer` keeps edits in a ref (read at save) and passes the immutable loaded content — zero re-renders per keystroke
- [ ] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": check task props → policy name resolution in admin import path (Pro report, likely same code path in CE)
- [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)

@ -42,7 +42,7 @@ import {
import "@mdxeditor/editor/style.css";
import { Box } from "@mui/material";
import i18next from "i18next";
import { useEffect, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import "./editor.css";
@ -89,29 +89,12 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
setNsLoaded(true);
});
}, []);
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
minHeight: props.displayOnly ? "100%" : "calc(100vh - 200px)",
}}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
props.onSaveShortcut?.();
}
}}
>
{nsLoaded && (
<MDXEditor
className={props.darkMode ? "dark-theme dark-editor" : undefined}
translation={(key, _defaultValue, interpolations) => {
return t("markdown_editor:" + key, interpolations);
}}
readOnly={props.readOnly}
onChange={props.onChange}
plugins={[
// MDXEditor invokes plugin.update() for every plugin on every render;
// keeping the array referentially stable lets the realm suppress
// downstream updates for unchanged params.
const plugins = useMemo(
() => [
diffSourcePlugin({
diffMarkdown: props.initialValue,
viewMode: "rich-text",
@ -244,7 +227,43 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
directiveDescriptors: [AdmonitionDirectiveDescriptor],
}),
markdownShortcutPlugin(),
]}
],
[
props.displayOnly,
props.initialValue,
props.imageUploadHandler,
props.imagePreviewHandler,
props.imageAutocompleteSuggestions,
],
);
const translation = useCallback(
(key: string, _defaultValue: string, interpolations?: Record<string, unknown>) =>
t("markdown_editor:" + key, interpolations),
[t],
);
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
minHeight: props.displayOnly ? "100%" : "calc(100vh - 200px)",
}}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
props.onSaveShortcut?.();
}
}}
>
{nsLoaded && (
<MDXEditor
className={props.darkMode ? "dark-theme dark-editor" : undefined}
translation={translation}
readOnly={props.readOnly}
onChange={props.onChange}
plugins={plugins}
contentEditableClassName={props.darkMode ? "markdown-body-dark" : "markdown-body-light"}
markdown={props.value}
/>
@ -265,4 +284,7 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
);
};
export default MarkdownEditor;
// Memoized: the parent re-renders on every keystroke (changedValue state);
// re-rendering MDXEditor re-runs every plugin's update() hook, which is the
// dominant source of typing lag on large documents.
export default memo(MarkdownEditor);

@ -1,6 +1,7 @@
import { LoadingButton } from "@mui/lab";
import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeMarkdownViewer } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
@ -29,7 +30,10 @@ const MarkdownViewer = () => {
const supportUpdate = canUpdate(displayOpt);
const [loading, setLoading] = useState(false);
const [value, setValue] = useState("");
const [changedValue, setChangedValue] = useState("");
// Edits are only read at save time; a ref keeps per-keystroke updates from
// re-rendering the viewer (and transitively re-running every MDXEditor
// plugin update hook).
const changedValue = useRef("");
const [loaded, setLoaded] = useState(false);
const [saved, setSaved] = useState(true);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -68,7 +72,7 @@ const MarkdownViewer = () => {
.then((res) => {
const content = new TextDecoder().decode(res);
setValue(content);
setChangedValue(content);
changedValue.current = content;
setLoaded(true);
})
.catch(() => {
@ -106,7 +110,7 @@ const MarkdownViewer = () => {
}
setLoading(true);
dispatch(saveMarkdown(changedValue, viewerState.file, viewerState.version, saveAs))
dispatch(saveMarkdown(changedValue.current, viewerState.file, viewerState.version, saveAs))
.then(() => {
setSaved(true);
})
@ -114,11 +118,11 @@ const MarkdownViewer = () => {
setLoading(false);
});
},
[changedValue, viewerState],
[viewerState],
);
const onChange = useCallback((v: string) => {
setChangedValue(v);
changedValue.current = v;
setSaved(false);
}, []);
@ -197,11 +201,11 @@ const MarkdownViewer = () => {
{loaded && (
<Suspense fallback={<ViewerLoading />}>
<MarkdownEditor
value={changedValue}
value={value}
readOnly={!supportUpdate}
darkMode={theme.palette.mode === "dark"}
initialValue={value}
onChange={(v) => onChange(v as string)}
onChange={onChange}
onSaveShortcut={onSaveShortcut}
imagePreviewHandler={imagePreviewHandler}
imageAutocompleteSuggestions={imageAutocompleteSuggestions}

Loading…
Cancel
Save