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); const deferredSave = debounceWithId(saveContent, 100);
export default function TabsView() { export default function TabsView() {
const sessions = useEditorStore((store) => store.sessions);
const documentPreview = useEditorStore((store) => store.documentPreview); const documentPreview = useEditorStore((store) => store.documentPreview);
const activeSessionId = useEditorStore((store) => store.activeSessionId); const activeSessionId = useEditorStore((store) => store.activeSessionId);
const arePropertiesVisible = useEditorStore( const arePropertiesVisible = useEditorStore(
@@ -95,7 +96,6 @@ export default function TabsView() {
const isTOCVisible = useEditorStore((store) => store.isTOCVisible); const isTOCVisible = useEditorStore((store) => store.isTOCVisible);
const [dropRef, overlayRef] = useDragOverlay(); const [dropRef, overlayRef] = useDragOverlay();
const sessions = useEditorStore.getState().sessions;
return ( return (
<> <>
{IS_DESKTOP_APP ? ( {IS_DESKTOP_APP ? (

View File

@@ -24,7 +24,6 @@ import "@notesnook/editor/styles/fonts.css";
import { import {
Toolbar, Toolbar,
useTiptap, useTiptap,
PortalProvider,
Editor, Editor,
AttachmentType, AttachmentType,
usePermissionHandler, usePermissionHandler,
@@ -41,7 +40,6 @@ import {
import { Box, Flex } from "@theme-ui/components"; import { Box, Flex } from "@theme-ui/components";
import { import {
PropsWithChildren, PropsWithChildren,
useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
@@ -61,7 +59,6 @@ import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store"; import { useEditorStore } from "../../stores/editor-store";
import { parseInternalLink } from "@notesnook/core"; import { parseInternalLink } from "@notesnook/core";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = ( export type OnChangeHandler = (
content: () => string, content: () => string,
@@ -402,7 +399,6 @@ function TiptapWrapper(
}, [theme]); }, [theme]);
return ( return (
<PortalProvider>
<Flex <Flex
ref={containerRef} ref={containerRef}
sx={{ sx={{
@@ -453,7 +449,6 @@ function TiptapWrapper(
/> />
</Box> </Box>
</Flex> </Flex>
</PortalProvider>
); );
} }
export default TiptapWrapper; export default TiptapWrapper;

View File

@@ -21,7 +21,6 @@ import {
Editor, Editor,
getFontById, getFontById,
getTableOfContents, getTableOfContents,
PortalProvider,
TiptapOptions, TiptapOptions,
Toolbar, Toolbar,
usePermissionHandler, usePermissionHandler,
@@ -801,11 +800,7 @@ const TiptapProvider = (): JSX.Element => {
} }
}, [settings.fontSize, settings.fontFamily]); }, [settings.fontSize, settings.fontFamily]);
return ( return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
<PortalProvider>
<Tiptap settings={settings} getContentDiv={getContentDiv} />
</PortalProvider>
);
}; };
export default TiptapProvider; 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 You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { import { TiptapOptions, getFontById, useTiptap } from "@notesnook/editor";
PortalProvider,
TiptapOptions,
getFontById,
useTiptap
} from "@notesnook/editor";
import { useThemeColors } from "@notesnook/theme"; import { useThemeColors } from "@notesnook/theme";
import { import {
useCallback, useCallback,
@@ -71,11 +66,7 @@ export const ReadonlyEditorProvider = (): JSX.Element => {
} }
}, [settings.fontSize, settings.fontFamily]); }, [settings.fontSize, settings.fontFamily]);
return ( return <Tiptap settings={settings} getContentDiv={getContentDiv} />;
<PortalProvider>
<Tiptap settings={settings} getContentDiv={getContentDiv} />
</PortalProvider>
);
}; };
const Tiptap = ({ const Tiptap = ({

View File

@@ -41,7 +41,7 @@ declare module "prosemirror-view" {
slice: Slice slice: Slice
): { dom: HTMLElement; text: string }; ): { dom: HTMLElement; text: string };
} }
const portalProviderAPI = new PortalProviderAPI();
export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView { export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
private domRef!: HTMLElement; private domRef!: HTMLElement;
private contentDOMWrapper?: Node; private contentDOMWrapper?: Node;
@@ -53,30 +53,10 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
pos = -1; pos = -1;
posEnd: number | undefined; 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( constructor(
node: PMNode, node: PMNode,
protected readonly editor: Editor, protected readonly editor: Editor,
protected readonly getPos: GetPosNode, protected readonly getPos: GetPosNode,
protected readonly portalProviderAPI: PortalProviderAPI,
protected readonly options: ReactNodeViewOptions<P> protected readonly options: ReactNodeViewOptions<P>
) { ) {
this.node = node; this.node = node;
@@ -105,11 +85,6 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
init() { init() {
this.domRef = this.createDomRef(); this.domRef = this.createDomRef();
this.domRef.ondragstart = (ev) => this.onDragStart(ev); 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() ?? {}; const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() ?? {};
@@ -131,12 +106,12 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
private render() { private render() {
if (process.env.NODE_ENV === "test") return; if (process.env.NODE_ENV === "test") return;
if (!this.domRef || !this.portalProviderAPI) { if (!this.domRef) {
console.warn("Cannot render node view"); console.warn("Cannot render node view");
return; return;
} }
this.portalProviderAPI.render(this.Component, this.domRef); portalProviderAPI.render(this.Component, this.domRef);
} }
createDomRef(): HTMLElement { createDomRef(): HTMLElement {
@@ -489,14 +464,7 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
} }
destroy() { destroy() {
this.detachObserver.disconnect(); portalProviderAPI.remove(this.domRef);
// 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();
this.domRef.remove(); this.domRef.remove();
} }
} }
@@ -507,18 +475,11 @@ export function createNodeView<TProps extends ReactNodeViewProps>(
) { ) {
return ({ node, getPos, editor }: NodeViewRendererProps) => { return ({ node, getPos, editor }: NodeViewRendererProps) => {
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos()); const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
if (!editor.storage.portalProviderAPI) return {};
return new ReactNodeView<TProps>( return new ReactNodeView<TProps>(node, editor as Editor, _getPos, {
node,
editor as Editor,
_getPos,
editor.storage.portalProviderAPI,
{
...options, ...options,
component component
} }).init();
).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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React, { import { FunctionComponent, PropsWithChildren } from "react";
FunctionComponent, import { flushSync } from "react-dom";
PropsWithChildren,
useContext,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { createPortal, flushSync } from "react-dom";
import { EventDispatcher } from "./event-dispatcher"; import { EventDispatcher } from "./event-dispatcher";
import { nanoid } from "nanoid"; import { Root, createRoot } from "react-dom/client";
export type BasePortalProviderProps = PropsWithChildren<unknown>; export type BasePortalProviderProps = PropsWithChildren<unknown>;
@@ -45,93 +37,31 @@ export type PortalRendererState = {
export class PortalProviderAPI extends EventDispatcher<Portals> { export class PortalProviderAPI extends EventDispatcher<Portals> {
portals: Map<HTMLElement, MountedPortal> = new Map(); portals: Map<HTMLElement, MountedPortal> = new Map();
roots: Map<HTMLElement, Root> = new Map();
constructor() { constructor() {
super(); super();
} }
/**
* Trigger an update in all subscribers.
*/
private update() {
this.emit("update", this.portals);
}
render(Component: FunctionComponent, container: HTMLElement) { render(Component: FunctionComponent, container: HTMLElement) {
const portal = this.portals.get(container); const root = this.roots.get(container) || createRoot(container);
this.portals.set(container, { Component, key: portal?.key ?? nanoid() }); flushSync(() => root.render(<Component />));
this.update(); this.roots.set(container, root);
}
/**
* 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() });
}
} }
remove(container: HTMLElement) { remove(container: HTMLElement) {
// Remove the portal which was being wrapped in the provided container. // if container is already unmounted (maybe by prosemirror),
this.portals.delete(container); // no need to proceed
if (!container.parentNode) return;
// Trigger an update const root = this.roots.get(container);
this.update(); 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) { export function TableNodeView(editor: TiptapEditor) {
if (!editor.storage.portalProviderAPI) return;
const api = editor.storage.portalProviderAPI;
class TableNode class TableNode
extends ReactNodeView<ReactNodeViewProps<unknown>> extends ReactNodeView<ReactNodeViewProps<unknown>>
implements NodeView implements NodeView
@@ -93,7 +91,6 @@ export function TableNodeView(editor: TiptapEditor) {
node, node,
editor, editor,
() => 0, // todo () => 0, // todo
api,
{ {
component: TableComponent, component: TableComponent,
shouldUpdate: (prev, next) => { shouldUpdate: (prev, next) => {

View File

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