diff --git a/ROADMAP.md b/ROADMAP.md index af891884..cce90a2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/frontend/src/component/Viewers/MarkdownEditor/Editor.tsx b/frontend/src/component/Viewers/MarkdownEditor/Editor.tsx index 7d53afba..073fc5b4 100644 --- a/frontend/src/component/Viewers/MarkdownEditor/Editor.tsx +++ b/frontend/src/component/Viewers/MarkdownEditor/Editor.tsx @@ -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 ( - { - if ((e.ctrlKey || e.metaKey) && e.key === "s") { - e.preventDefault(); - props.onSaveShortcut?.(); - } - }} - > - {nsLoaded && ( - { - 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) => + t("markdown_editor:" + key, interpolations), + [t], + ); + + return ( + { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + props.onSaveShortcut?.(); + } + }} + > + {nsLoaded && ( + @@ -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); diff --git a/frontend/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx b/frontend/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx index bc55fdb0..bb5941eb 100644 --- a/frontend/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx +++ b/frontend/src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx @@ -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); @@ -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 && ( }> onChange(v as string)} + onChange={onChange} onSaveShortcut={onSaveShortcut} imagePreviewHandler={imagePreviewHandler} imageAutocompleteSuggestions={imageAutocompleteSuggestions}