diff --git a/.yarn/versions/5e6945cc.yml b/.yarn/versions/5e6945cc.yml
new file mode 100644
index 0000000..766dd55
--- /dev/null
+++ b/.yarn/versions/5e6945cc.yml
@@ -0,0 +1,2 @@
+releases:
+ "@handlewithcare/react-codemirror": patch
diff --git a/README.md b/README.md
index f2ef2c6..f8f7d9c 100644
--- a/README.md
+++ b/README.md
@@ -22,12 +22,16 @@ yarn add @handlewithcare/react-codemirror
- [Overview](#overview)
- [Usage](#usage)
- [Dynamic extensions](#dynamic-extensions)
+ - [`useReconfigure`](#usereconfigure)
+ - [`useSyncExtensions`](#usesyncextensions)
+ - [Other hooks](#other-hooks)
- [API](#api)
- [`CodeMirror`](#codemirror)
- [`CodeMirrorEditor`](#codemirroreditor)
- [`react`](#react)
- [`useEditorState`](#useeditorstate)
- - [`useCompartment`](#usecompartment)
+ - [`useReconfigure`](#usereconfigure-1)
+ - [`useSyncExtensions`](#usesyncextensions-1)
- [`useEditorEventCallback`](#useeditoreventcallback)
- [`useEditorEffect`](#useeditoreffect)
- [Looking for someone to collaborate with?](#looking-for-someone-to-collaborate-with)
@@ -84,8 +88,13 @@ sync with the CodeMirror EditorState.
### Dynamic extensions
+#### `useReconfigure`
+
The `useReconfigure` hook can be used to configure dynamic CodeMirror
-extensions. Here’s an example, using a simple theme switcher:
+extensions. The function returned by `useReconfigure` should only be used in an
+event callback. If you need to keep a compartment in sync with some external
+state, see [`useSyncExtensions`](#usesyncexetensions). Here’s an example with
+`useReconfigure`, using a simple theme switcher:
```tsx
// ThemePicker.tsx
@@ -152,6 +161,61 @@ function CodeEditor() {
}
```
+#### `useSyncExtensions`
+
+The `useSyncExtensions` hook can be used keep the CodeMirror EditorState's
+extensions in sync with external state. In general, this should be avoided —
+it's better to either derive state _from_ your CodeMirror EditorState or lift
+the EditorState into your global/top-level state and update it at the same time
+as you update other state (like the user's theme). However, sometimes you need
+to have a local EditorState that is derived from state that lives higher up in
+the tree.
+
+The `useSyncExtensions` hook _must_ be used in the component that owns the
+EditorState, otherwise React will throw an error about updating state from
+another component.
+
+```tsx
+function ThemePicker() {
+ const theme = useSelector((state) => state.theme);
+ const dispatch = useDispatch();
+
+ return (
+
+ );
+}
+
+function Editor() {
+ const [state, setState] = useState(editorState);
+
+ const theme = useSelector((state) => state.theme);
+ const themeExtension = theme === "light" ? [] : oneDark;
+ useSyncExtensions([themeCompartment], [themeExtension], state, setState);
+
+ const dispatchTransactions = useCallback((trs: readonly Transaction[]) => {
+ setState(trs[trs.length - 1].state);
+ }, []);
+
+ return (
+
+
+
+ );
+}
+```
+
+### Other hooks
+
The `useEditorEventCallback` hook is a more general purpose hook that allows
components that are descendants of the `CodeMirror` component to dispatch a
transaction or otherwise interact with the CodeMirror EditorView in an event
@@ -295,7 +359,7 @@ function useEditorState(): EditorState;
Provides access to the current EditorState value.
-### `useCompartment`
+### `useReconfigure`
```ts
function useReconfigure(
@@ -334,6 +398,54 @@ export function ThemePicker() {
}
```
+### `useSyncExtensions`
+
+```ts
+function useSyncExtensions(
+ compartments: Compartment[],
+ extensions: Extension[],
+ editorState: EditorState,
+ setEditorState: (editorState: EditorState) => void,
+): void;
+```
+
+Keep compartmentalized extensions in sync with external state.
+
+If the state that determines the value of a compartment necessarily lives
+outside the CodeMirror EditorState (say, an app-wide theme picker), this hook
+can be used to keep it in sync with the EditorState.
+
+To avoid state tearing, this hook calls the `setEditorState` argument in the
+render phase. This means that it _must_ be used in the component that owns the
+EditorState. If your EditorState lives in a global state manager, you should not
+use this hook.
+
+Example usage:
+
+```tsx
+function Editor() {
+ const [state, setState] = useState(editorState);
+
+ const theme = useSelector((state) => state.theme);
+ const themeExtension = theme === "light" ? [] : oneDark;
+ useSyncExtensions([themeCompartment], [themeExtension], state, setState);
+
+ const dispatchTransactions = useCallback((trs: readonly Transaction[]) => {
+ setState(trs[trs.length - 1].state);
+ }, []);
+
+ return (
+
+
+
+ );
+}
+```
+
### `useEditorEventCallback`
```ts
diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts
index 19bd234..82f30da 100644
--- a/src/hooks/useEditor.ts
+++ b/src/hooks/useEditor.ts
@@ -46,36 +46,24 @@ export function useEditor(
const seen = useRef>(new Set(state.field(tracking)));
- function dispatchTransactions(trs: readonly Transaction[], view: EditorView) {
- if (!options.state) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- setState(trs[trs.length - 1]!.state);
- }
-
- if (options.dispatchTransactions) {
- options.dispatchTransactions(trs, view);
- }
- }
- const dispatchTransactionsRef = useRef(dispatchTransactions);
-
- const config = {
- ...options,
- state,
- dispatchTransactions: function (
- trs: readonly Transaction[],
- view: EditorView,
- ) {
- dispatchTransactionsRef.current(trs, view);
- },
- };
-
const [view, setView] = useState(null);
const createEditorView = useEffectEvent((parent: HTMLDivElement | null) => {
if (parent) {
return new EditorView({
parent,
- ...config,
+ ...options,
+ state,
+ dispatchTransactions(trs: readonly Transaction[], view: EditorView) {
+ if (!options.state) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ setState(trs[trs.length - 1]!.state);
+ }
+
+ if (options.dispatchTransactions) {
+ options.dispatchTransactions(trs, view);
+ }
+ },
});
}
return null;
@@ -91,8 +79,6 @@ export function useEditor(
}, [parent, createEditorView]);
useClientLayoutEffect(() => {
- dispatchTransactionsRef.current = dispatchTransactions;
-
const trs = state.field(tracking);
const newTrs = trs.filter((tr) => !seen.current.has(tr));
diff --git a/src/hooks/useReconfigure.ts b/src/hooks/useReconfigure.ts
index 01e00e2..a5447b2 100644
--- a/src/hooks/useReconfigure.ts
+++ b/src/hooks/useReconfigure.ts
@@ -14,7 +14,7 @@ import { useEditorEventCallback } from "./useEditorEventCallback.js";
* const state = useEditorState();
* const theme = themeCompartment.get(state);
* const dark = theme === oneDark;
- * const reconfigureTheme = useReconfigure(themeCompartment)*;
+ * const reconfigureTheme = useReconfigure(themeCompartment);
*
* return (
*