editor: fix performance issues due to using portals for react node views

This commit is contained in:
Abdullah Atta
2024-05-01 13:26:08 +05:00
committed by Abdullah Atta
parent 716239abfd
commit a8db65b5af
8 changed files with 80 additions and 217 deletions

View File

@@ -87,6 +87,7 @@ function saveContent(noteId: string, ignoreEdit: boolean, content: string) {
const deferredSave = debounceWithId(saveContent, 100);
export default function TabsView() {
const sessions = useEditorStore((store) => store.sessions);
const documentPreview = useEditorStore((store) => store.documentPreview);
const activeSessionId = useEditorStore((store) => store.activeSessionId);
const arePropertiesVisible = useEditorStore(
@@ -95,7 +96,6 @@ export default function TabsView() {
const isTOCVisible = useEditorStore((store) => store.isTOCVisible);
const [dropRef, overlayRef] = useDragOverlay();
const sessions = useEditorStore.getState().sessions;
return (
<>
{IS_DESKTOP_APP ? (

View File

@@ -24,7 +24,6 @@ import "@notesnook/editor/styles/fonts.css";
import {
Toolbar,
useTiptap,
PortalProvider,
Editor,
AttachmentType,
usePermissionHandler,
@@ -41,7 +40,6 @@ import {
import { Box, Flex } from "@theme-ui/components";
import {
PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
@@ -61,7 +59,6 @@ import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store";
import { parseInternalLink } from "@notesnook/core";
import Skeleton from "react-loading-skeleton";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = (
content: () => string,
@@ -402,58 +399,56 @@ function TiptapWrapper(
}, [theme]);
return (
<PortalProvider>
<Flex
ref={containerRef}
sx={{
flex: 1,
flexDirection: "column",
".tiptap.ProseMirror": { pb: 150 }
<Flex
ref={containerRef}
sx={{
flex: 1,
flexDirection: "column",
".tiptap.ProseMirror": { pb: 150 }
}}
>
<TipTap
{...props}
onLoad={(editor) => {
props.onLoad?.(editor);
containerRef.current
?.querySelector(".editor-loading-container")
?.remove();
}}
>
<TipTap
{...props}
onLoad={(editor) => {
props.onLoad?.(editor);
containerRef.current
?.querySelector(".editor-loading-container")
?.remove();
}}
editorContainer={() => {
if (editorContainerRef.current) return editorContainerRef.current;
const editorContainer = document.createElement("div");
editorContainer.classList.add("selectable");
editorContainer.style.flex = "1";
editorContainer.style.cursor = "text";
editorContainer.style.color =
theme.scopes.editor?.primary?.paragraph ||
theme.scopes.base.primary.paragraph;
editorContainer.style.fontSize = `${editorConfig.fontSize}px`;
editorContainer.style.fontFamily =
getFontById(editorConfig.fontFamily)?.font || "sans-serif";
editorContainerRef.current = editorContainer;
return editorContainer;
}}
fontFamily={editorConfig.fontFamily}
fontSize={editorConfig.fontSize}
editorContainer={() => {
if (editorContainerRef.current) return editorContainerRef.current;
const editorContainer = document.createElement("div");
editorContainer.classList.add("selectable");
editorContainer.style.flex = "1";
editorContainer.style.cursor = "text";
editorContainer.style.color =
theme.scopes.editor?.primary?.paragraph ||
theme.scopes.base.primary.paragraph;
editorContainer.style.fontSize = `${editorConfig.fontSize}px`;
editorContainer.style.fontFamily =
getFontById(editorConfig.fontFamily)?.font || "sans-serif";
editorContainerRef.current = editorContainer;
return editorContainer;
}}
fontFamily={editorConfig.fontFamily}
fontSize={editorConfig.fontSize}
/>
{props.children}
<Box className="editor-loading-container">
<Skeleton
enableAnimation={false}
height={22}
style={{ marginTop: 16 }}
count={2}
/>
{props.children}
<Box className="editor-loading-container">
<Skeleton
enableAnimation={false}
height={22}
style={{ marginTop: 16 }}
count={2}
/>
<Skeleton
enableAnimation={false}
height={22}
width={25}
style={{ marginTop: 16 }}
/>
</Box>
</Flex>
</PortalProvider>
<Skeleton
enableAnimation={false}
height={22}
width={25}
style={{ marginTop: 16 }}
/>
</Box>
</Flex>
);
}
export default TiptapWrapper;

View File

@@ -21,7 +21,6 @@ import {
Editor,
getFontById,
getTableOfContents,
PortalProvider,
TiptapOptions,
Toolbar,
usePermissionHandler,
@@ -801,11 +800,7 @@ const TiptapProvider = (): JSX.Element => {
}
}, [settings.fontSize, settings.fontFamily]);
return (
<PortalProvider>
<Tiptap settings={settings} getContentDiv={getContentDiv} />
</PortalProvider>
);
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
};
export default TiptapProvider;

View File

@@ -16,12 +16,7 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
PortalProvider,
TiptapOptions,
getFontById,
useTiptap
} from "@notesnook/editor";
import { TiptapOptions, getFontById, useTiptap } from "@notesnook/editor";
import { useThemeColors } from "@notesnook/theme";
import {
useCallback,
@@ -71,11 +66,7 @@ export const ReadonlyEditorProvider = (): JSX.Element => {
}
}, [settings.fontSize, settings.fontFamily]);
return (
<PortalProvider>
<Tiptap settings={settings} getContentDiv={getContentDiv} />
</PortalProvider>
);
return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
};
const Tiptap = ({

View File

@@ -41,7 +41,7 @@ declare module "prosemirror-view" {
slice: Slice
): { dom: HTMLElement; text: string };
}
const portalProviderAPI = new PortalProviderAPI();
export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
private domRef!: HTMLElement;
private contentDOMWrapper?: Node;
@@ -53,30 +53,10 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
pos = -1;
posEnd: number | undefined;
// in order to cleanly unmount a React Portal, we have to preserve the
// dom structure for the full node. However, Prosemirror/browser can,
// sometimes, detach the node before React is able to call "unmount".
// Unmounting a React child whose DOM counterpart is already removed
// results in a DOMException.
// To fix that, we observe and store all the detached subnodes and later add
// them back when we are ready to destroy our node view once & for all.
// This wouldn't be necessary if React allowed for a way to "sync" the DOM
// changes to its Virtual DOM.
detached: Set<Node> = new Set();
detachObserver = new MutationObserver((mutations) => {
const filtered = mutations.filter(
(m) => m.target === this.domRef && m.removedNodes.length > 0
);
for (const mutation of filtered) {
for (const node of mutation.removedNodes) this.detached.add(node);
}
});
constructor(
node: PMNode,
protected readonly editor: Editor,
protected readonly getPos: GetPosNode,
protected readonly portalProviderAPI: PortalProviderAPI,
protected readonly options: ReactNodeViewOptions<P>
) {
this.node = node;
@@ -105,11 +85,6 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
init() {
this.domRef = this.createDomRef();
this.domRef.ondragstart = (ev) => this.onDragStart(ev);
this.detachObserver.observe(this.domRef, {
childList: true,
subtree: true
});
// this.setDomAttrs(this.node, this.domRef);
const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {};
@@ -131,12 +106,12 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
private render() {
if (process.env.NODE_ENV === "test") return;
if (!this.domRef || !this.portalProviderAPI) {
if (!this.domRef) {
console.warn("Cannot render node view");
return;
}
this.portalProviderAPI.render(this.Component, this.domRef);
portalProviderAPI.render(this.Component, this.domRef);
}
createDomRef(): HTMLElement {
@@ -489,14 +464,7 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
}
destroy() {
this.detachObserver.disconnect();
// add back the detached nodes because React expects an untouched
// DOM representation (and there's no way to reconcile the DOM later).
for (const node of this.detached) {
this.domRef.appendChild(node);
}
this.portalProviderAPI.remove(this.domRef);
this.detached.clear();
portalProviderAPI.remove(this.domRef);
this.domRef.remove();
}
}
@@ -507,18 +475,11 @@ export function createNodeView<TProps extends ReactNodeViewProps>(
) {
return ({ node, getPos, editor }: NodeViewRendererProps) => {
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
if (!editor.storage.portalProviderAPI) return {};
return new ReactNodeView<TProps>(
node,
editor as Editor,
_getPos,
editor.storage.portalProviderAPI,
{
...options,
component
}
).init();
return new ReactNodeView<TProps>(node, editor as Editor, _getPos, {
...options,
component
}).init();
};
}

View File

@@ -17,18 +17,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {
FunctionComponent,
PropsWithChildren,
useContext,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { createPortal, flushSync } from "react-dom";
import { FunctionComponent, PropsWithChildren } from "react";
import { flushSync } from "react-dom";
import { EventDispatcher } from "./event-dispatcher";
import { nanoid } from "nanoid";
import { Root, createRoot } from "react-dom/client";
export type BasePortalProviderProps = PropsWithChildren<unknown>;
@@ -45,93 +37,31 @@ export type PortalRendererState = {
export class PortalProviderAPI extends EventDispatcher<Portals> {
portals: Map<HTMLElement, MountedPortal> = new Map();
roots: Map<HTMLElement, Root> = new Map();
constructor() {
super();
}
/**
* Trigger an update in all subscribers.
*/
private update() {
this.emit("update", this.portals);
}
render(Component: FunctionComponent, container: HTMLElement) {
const portal = this.portals.get(container);
this.portals.set(container, { Component, key: portal?.key ?? nanoid() });
this.update();
}
/**
* Force an update in all the portals by setting new keys for every portal.
*
* Delete all orphaned containers (deleted from the DOM). This is useful for
* Decoration where there is no destroy method.
*/
forceUpdate(): void {
for (const [container, { Component }] of this.portals) {
this.portals.set(container, { Component, key: nanoid() });
}
const root = this.roots.get(container) || createRoot(container);
flushSync(() => root.render(<Component />));
this.roots.set(container, root);
}
remove(container: HTMLElement) {
// Remove the portal which was being wrapped in the provided container.
this.portals.delete(container);
// if container is already unmounted (maybe by prosemirror),
// no need to proceed
if (!container.parentNode) return;
// Trigger an update
this.update();
const root = this.roots.get(container);
if (!root) return;
this.roots.delete(container);
try {
root.unmount();
} catch {
// ignore
}
}
}
const PortalProviderContext = React.createContext<
PortalProviderAPI | undefined
>(undefined);
export function usePortalProvider() {
return useContext(PortalProviderContext);
}
export function PortalProvider(props: PropsWithChildren) {
const portalProviderAPI = useMemo(() => new PortalProviderAPI(), []);
return (
<PortalProviderContext.Provider value={portalProviderAPI}>
{props.children}
<PortalRenderer portalProviderAPI={portalProviderAPI} />
</PortalProviderContext.Provider>
);
}
function PortalRenderer(props: { portalProviderAPI: PortalProviderAPI }) {
const { portalProviderAPI } = props;
const mounted = useRef(true);
const [portals, setPortals] = useState(() =>
Array.from(portalProviderAPI.portals.entries())
);
useEffect(() => {
mounted.current = true;
function onUpdate(portalMap: Portals) {
// we have to make sure the component is mounted otherwise React
// throws an error.
if (!mounted.current) return;
// flushSync is necessary here, otherwise we get into a loop where
// ProseMirror destroys and recreates the node view over and over again.
flushSync(() => setPortals(Array.from(portalMap.entries())));
}
portalProviderAPI.on("update", onUpdate);
return () => {
mounted.current = false;
portalProviderAPI.off("update", onUpdate);
};
}, [portalProviderAPI]);
return (
<>
{portals.map(([container, { Component, key }]) =>
createPortal(<Component />, container, key)
)}
</>
);
}

View File

@@ -82,8 +82,6 @@ export function TableComponent(props: ReactNodeViewProps) {
}
export function TableNodeView(editor: TiptapEditor) {
if (!editor.storage.portalProviderAPI) return;
const api = editor.storage.portalProviderAPI;
class TableNode
extends ReactNodeView<ReactNodeViewProps<unknown>>
implements NodeView
@@ -93,7 +91,6 @@ export function TableNodeView(editor: TiptapEditor) {
node,
editor,
() => 0, // todo
api,
{
component: TableComponent,
shouldUpdate: (prev, next) => {

View File

@@ -61,7 +61,6 @@ import OrderedList from "./extensions/ordered-list";
import { OutlineList } from "./extensions/outline-list";
import { OutlineListItem } from "./extensions/outline-list-item";
import { Paragraph } from "./extensions/paragraph";
import { PortalProviderAPI, usePortalProvider } from "./extensions/react";
import { SearchReplace } from "./extensions/search-replace";
import { Table } from "./extensions/table";
import TableCell from "./extensions/table-cell";
@@ -87,7 +86,6 @@ import { useEditorSearchStore } from "./toolbar/stores/search-store";
import { DiffHighlighter } from "./extensions/diff-highlighter";
interface TiptapStorage {
portalProviderAPI?: PortalProviderAPI;
dateFormat?: DateTimeOptions["dateFormat"];
timeFormat?: DateTimeOptions["timeFormat"];
openLink?: (url: string) => void;
@@ -124,7 +122,7 @@ export type TiptapOptions = EditorOptions &
Omit<WebClipOptions, "HTMLAttributes"> &
Omit<ImageOptions, "HTMLAttributes"> &
DateTimeOptions &
Omit<TiptapStorage, "portalProviderAPI"> & {
TiptapStorage & {
downloadOptions?: DownloadOptions;
isMobile?: boolean;
doubleSpacedLines?: boolean;
@@ -153,7 +151,6 @@ const useTiptap = (
...restOptions
} = options;
const PortalProviderAPI = usePortalProvider();
const setIsMobile = useToolbarStore((store) => store.setIsMobile);
const closeAllPopups = useToolbarStore((store) => store.closeAllPopups);
const setDownloadOptions = useToolbarStore(
@@ -356,7 +353,6 @@ const useTiptap = (
})
],
onBeforeCreate: ({ editor }) => {
editor.storage.portalProviderAPI = PortalProviderAPI;
editor.storage.dateFormat = dateFormat;
editor.storage.timeFormat = timeFormat;
@@ -378,7 +374,6 @@ const useTiptap = (
downloadAttachment,
openAttachmentPicker,
getAttachmentData,
PortalProviderAPI,
onBeforeCreate,
openLink,
dateFormat,
@@ -403,7 +398,6 @@ const useTiptap = (
export { type Fragment } from "prosemirror-model";
export { type Attachment, type AttachmentType } from "./extensions/attachment";
export { type ImageAttributes } from "./extensions/image";
export * from "./extensions/react";
export * from "./toolbar";
export * from "./types";
export * from "./utils/word-counter";