Skip to content

Commit

Permalink
Update props atomically and consistently
Browse files Browse the repository at this point in the history
Whether the editor state is controlled or uncontrolled, update all props
atomically and only in a layout effect after rendering.

- Put uncontrolled state into a state hook. The default dispatch can use
  this hook to update the editor state so that view updates always occur
  in a layout effect after a render cycle, even with uncontrolled state.

- Type the components and hooks to account for passing both defaultState
  and state. Handle this in the same way that React itself does, writing
  a warning to the console on the first occurrence, such that users that
  do not use TypeScript still get some guidance.

- Merge the layout effects that update the view so that all of the props
  of the view get updated simultaneously. Otherwise, any view props that
  may close over external state, such as decorations, may be out of date
  during the update of non-state props, which can cause errors.

- Push all logic from the inner ProseMirror component into the hook so
  that all props beyond the base ProseMirror editor props get handled in
  one place.

- Inline the inner component into the main ProseMirror component module.
  Name the component simply "Editor" to align with its purpose of owning
  the editor view and providing the editor context.

- Give the contexts display names so that their purpose is clearer when
  viewing them in React development tools.

- Use "state" in all examples, instead of "editorState", for simplicity.
  This library is not Redux and software developers are used to handling
  naming collisions, if they occur. Redux users are used to seeing React
  examples that use the state hook and knowing that it is different from
  Redux state. ProseMirror state is similar in this way.
  • Loading branch information
tilgovi committed Feb 6, 2024
1 parent 4831f16 commit c67d670
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 223 deletions.
37 changes: 18 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,14 @@ import { ProseMirror } from "@nytimes/react-prosemirror";

export function ProseMirrorEditor() {
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(
EditorState.create({ schema })
);
const [state, setState] = useState(EditorState.create({ schema }));

return (
<ProseMirror
mount={mount}
state={editorState}
state={state}
dispatchTransaction={(tr) => {
setEditorState((s) => s.apply(tr));
setState((s) => s.apply(tr));
}}
>
<div ref={setMount} />
Expand Down Expand Up @@ -194,14 +192,14 @@ import { SelectionWidget } from "./SelectionWidget.tsx";

export function ProseMirrorEditor() {
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(EditorState.create({ schema }))
const [state, setState] = useState(EditorState.create({ schema }))

return (
<ProseMirror
mount={mount}
state={editorState}
state={state}
dispatchTransaction={(tr) => {
setEditorState(s => s.apply(tr))
setState(s => s.apply(tr))
}}
>
{/*
Expand Down Expand Up @@ -249,14 +247,12 @@ import { BoldButton } from "./BoldButton.tsx";

export function ProseMirrorEditor() {
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(
EditorState.create({ schema })
);
const [state, setState] = useState(EditorState.create({ schema }));

return (
<ProseMirror
mount={mount}
state={editorState}
state={state}
dispatchTransaction={(tr) => {
setEditorState((s) => s.apply(tr));
}}
Expand Down Expand Up @@ -380,7 +376,7 @@ const reactNodeViews = {
}),
};

const editorState = EditorState.create({
const state = EditorState.create({
schema,
// You must add the react plugin if you use
// the useNodeViews or useNodePos hook.
Expand All @@ -392,7 +388,7 @@ function ProseMirrorEditor() {
const [mount, setMount] = useState<HTMLElement | null>(null);

return (
<ProseMirror mount={mount} defaultState={editorState} nodeViews={nodeViews}>
<ProseMirror mount={mount} nodeViews={nodeViews} defaultState={state}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
Expand All @@ -406,11 +402,14 @@ function ProseMirrorEditor() {

```tsx
type ProseMirror = (
props: {
mount: HTMLElement;
children: ReactNode;
} & DirectEditorProps &
({ defaultState: EditorState } | { state: EditorState })
props: EditorProps & {
mount: HTMLElement | null;
children?: ReactNode | null;
defaultState?: EditorState;
state?: EditorState;
plugins?: readonly Plugin[];
dispatchTransaction?(this: EditorView, tr: Transaction): void;
}
) => JSX.Element;
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^27.0.0",
"@types/node": "^20.11.16",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
Expand Down
28 changes: 24 additions & 4 deletions src/components/ProseMirror.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import React from "react";
import type { EditorState, Plugin, Transaction } from "prosemirror-state";
import type { EditorProps, EditorView } from "prosemirror-view";
import React, { memo } from "react";
import type { ReactNode } from "react";

import { EditorProvider } from "../contexts/EditorContext.js";
import { LayoutGroup } from "../contexts/LayoutGroup.js";
import { useEditorView } from "../hooks/useEditorView.js";

import { ProseMirrorInner, ProseMirrorProps } from "./ProseMirrorInner.js";
interface Props extends EditorProps {
mount: HTMLElement | null;
children?: ReactNode | null;
defaultState?: EditorState;
state?: EditorState;
plugins?: readonly Plugin[];
dispatchTransaction?(this: EditorView, tr: Transaction): void;
}

function Editor({ mount, children, ...props }: Props) {
const value = useEditorView(mount, props);
return <EditorProvider value={value}>{children}</EditorProvider>;
}

/**
* Renders the ProseMirror View onto a DOM mount.
Expand All @@ -25,10 +42,13 @@ import { ProseMirrorInner, ProseMirrorProps } from "./ProseMirrorInner.js";
* }
* ```
*/
export function ProseMirror(props: ProseMirrorProps) {
function ProseMirror(props: Props) {
return (
<LayoutGroup>
<ProseMirrorInner {...props} />
<Editor {...props} />
</LayoutGroup>
);
}

const MemoizedProseMirror = memo(ProseMirror);
export { MemoizedProseMirror as ProseMirror };
76 changes: 0 additions & 76 deletions src/components/ProseMirrorInner.tsx

This file was deleted.

49 changes: 28 additions & 21 deletions src/components/__tests__/ProseMirror.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import React, { useEffect, useState } from "react";

import { useNodeViews } from "../../hooks/useNodeViews.js";
Expand All @@ -14,6 +13,12 @@ import {
} from "../../testing/setupProseMirrorView.js";
import { ProseMirror } from "../ProseMirror.js";

// Mock `ReactDOM.flushSync` to call `act` to flush updates from DOM mutations.
jest.mock("react-dom", () => ({
...jest.requireActual("react-dom"),
flushSync: (fn: () => void) => act(fn),
}));

describe("ProseMirror", () => {
beforeAll(() => {
setupProseMirrorView();
Expand All @@ -32,11 +37,12 @@ describe("ProseMirror", () => {
const [mount, setMount] = useState<HTMLDivElement | null>(null);

return (
<ProseMirror mount={mount} state={editorState}>
<ProseMirror mount={mount} defaultState={editorState}>
<div data-testid="editor" ref={setMount} />
</ProseMirror>
);
}

const user = userEvent.setup();
render(<TestEditor />);

Expand All @@ -46,41 +52,44 @@ describe("ProseMirror", () => {
expect(editor.textContent).toBe("Hello, world!");
});

it("supports lifted editor state", async () => {
it("supports controlling the editor state", async () => {
const schema = new Schema({
nodes: {
text: {},
doc: { content: "text*" },
},
});
let outerEditorState = EditorState.create({ schema });

let observedState = EditorState.create({ schema });

function TestEditor() {
const [editorState, setEditorState] = useState(outerEditorState);
const [state, setState] = useState(observedState);
const [mount, setMount] = useState<HTMLDivElement | null>(null);

useEffect(() => {
outerEditorState = editorState;
}, [editorState]);
observedState = state;
}, [state]);

return (
<ProseMirror
mount={mount}
state={editorState}
dispatchTransaction={(tr) =>
act(() => setEditorState(editorState.apply(tr)))
}
state={state}
dispatchTransaction={(tr) => {
setState((s) => s.apply(tr));
}}
>
<div data-testid="editor" ref={setMount} />
</ProseMirror>
);
}

const user = userEvent.setup();
render(<TestEditor />);

const editor = screen.getByTestId("editor");
await user.type(editor, "Hello, world!");

expect(outerEditorState.doc.textContent).toBe("Hello, world!");
expect(observedState.doc.textContent).toBe("Hello, world!");
});

it("supports React NodeViews", async () => {
Expand All @@ -106,27 +115,25 @@ describe("ProseMirror", () => {
};

function TestEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [mount, setMount] = useState<HTMLDivElement | null>(null);
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [state, setState] = useState(editorState);

return (
<ProseMirror
mount={mount}
state={editorState}
dispatchTransaction={function (this: EditorView, tr) {
// We have to wrap the update in `act` to handle all of
// the async portal registering and component rendering that
// happens "out of band" because it's triggered by ProseMirror,
// not React.
act(() => this.updateState(this.state.apply(tr)));
}}
state={state}
nodeViews={nodeViews}
dispatchTransaction={(tr) => {
setState((s) => s.apply(tr));
}}
>
<div data-testid="editor" ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}

const user = userEvent.setup();
render(<TestEditor />);

Expand Down
2 changes: 1 addition & 1 deletion src/contexts/EditorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createContext } from "react";

import type { EventHandler } from "../plugins/componentEventListeners";

interface EditorContextValue {
export interface EditorContextValue {
editorView: EditorView | null;
editorState: EditorState;
registerEventListener<EventType extends keyof DOMEventMap>(
Expand Down
Loading

0 comments on commit c67d670

Please sign in to comment.