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 - 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) - 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. - #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) - [ ] #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 - [ ] #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) - [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 "@mdxeditor/editor/style.css";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import i18next from "i18next"; import i18next from "i18next";
import { useEffect, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import "./editor.css"; import "./editor.css";
@ -89,29 +89,12 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
setNsLoaded(true); setNsLoaded(true);
}); });
}, []); }, []);
return (
<Box // MDXEditor invokes plugin.update() for every plugin on every render;
sx={{ // keeping the array referentially stable lets the realm suppress
display: "flex", // downstream updates for unchanged params.
flexDirection: "column", const plugins = useMemo(
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={[
diffSourcePlugin({ diffSourcePlugin({
diffMarkdown: props.initialValue, diffMarkdown: props.initialValue,
viewMode: "rich-text", viewMode: "rich-text",
@ -244,7 +227,43 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
directiveDescriptors: [AdmonitionDirectiveDescriptor], directiveDescriptors: [AdmonitionDirectiveDescriptor],
}), }),
markdownShortcutPlugin(), 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"} contentEditableClassName={props.darkMode ? "markdown-body-dark" : "markdown-body-light"}
markdown={props.value} 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 { LoadingButton } from "@mui/lab";
import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material"; import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { closeMarkdownViewer } from "../../../redux/globalStateSlice.ts"; import { closeMarkdownViewer } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
@ -29,7 +30,10 @@ const MarkdownViewer = () => {
const supportUpdate = canUpdate(displayOpt); const supportUpdate = canUpdate(displayOpt);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [value, setValue] = useState(""); 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 [loaded, setLoaded] = useState(false);
const [saved, setSaved] = useState(true); const [saved, setSaved] = useState(true);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@ -68,7 +72,7 @@ const MarkdownViewer = () => {
.then((res) => { .then((res) => {
const content = new TextDecoder().decode(res); const content = new TextDecoder().decode(res);
setValue(content); setValue(content);
setChangedValue(content); changedValue.current = content;
setLoaded(true); setLoaded(true);
}) })
.catch(() => { .catch(() => {
@ -106,7 +110,7 @@ const MarkdownViewer = () => {
} }
setLoading(true); setLoading(true);
dispatch(saveMarkdown(changedValue, viewerState.file, viewerState.version, saveAs)) dispatch(saveMarkdown(changedValue.current, viewerState.file, viewerState.version, saveAs))
.then(() => { .then(() => {
setSaved(true); setSaved(true);
}) })
@ -114,11 +118,11 @@ const MarkdownViewer = () => {
setLoading(false); setLoading(false);
}); });
}, },
[changedValue, viewerState], [viewerState],
); );
const onChange = useCallback((v: string) => { const onChange = useCallback((v: string) => {
setChangedValue(v); changedValue.current = v;
setSaved(false); setSaved(false);
}, []); }, []);
@ -197,11 +201,11 @@ const MarkdownViewer = () => {
{loaded && ( {loaded && (
<Suspense fallback={<ViewerLoading />}> <Suspense fallback={<ViewerLoading />}>
<MarkdownEditor <MarkdownEditor
value={changedValue} value={value}
readOnly={!supportUpdate} readOnly={!supportUpdate}
darkMode={theme.palette.mode === "dark"} darkMode={theme.palette.mode === "dark"}
initialValue={value} initialValue={value}
onChange={(v) => onChange(v as string)} onChange={onChange}
onSaveShortcut={onSaveShortcut} onSaveShortcut={onSaveShortcut}
imagePreviewHandler={imagePreviewHandler} imagePreviewHandler={imagePreviewHandler}
imageAutocompleteSuggestions={imageAutocompleteSuggestions} imageAutocompleteSuggestions={imageAutocompleteSuggestions}

Loading…
Cancel
Save