Props to plugin views

What’s the best way to pass props into plugin views so that they update when props change (not only when state changes)?

Here’s a long-form version of this question:

React:

view = fn(props, state)

Prosemirror:

view = (props => fn(state))(props)

If those props change during run-time, the only way to get the view plugins to update would be to pass an empty transaction with a meta-field set, but that’s pretty annoying, because it requires a lot of moving un-typed parts: subscribing to the prop changes, dispatching transaction when they change, receiving those in the plugin’s state.apply, storing those props in the plugin state, and then reading them in view.

Alternatively, those initial props can be subscriptions to the props rather than initial values of the props themselves.

Or you can just bail out on using the view field for plugins, and read the plugin states in react components.

What’s the best way to have the view update on prop changes? Or is the bail-out method the best choice?

Note that props usually just refers to state from “higher up” in your app. So this is an issue when plugging in Prosemirror to a bigger app.

1 Like

Plugin views have their update method called as normal when the editor’s props change. Except when the state is reconfigured—then they are destroyed and recreated entirely.

I should have specified; I mean plugin view specific props.

Like if user preference for accent color is stored higher up in the app, and its passed as a prop to a view for a plugin. Not editor state props, basically.

That sounds like a concept entirely outside ProseMirror. Put it in a state field and update it when it changes using a transaction, if you want to tie it into ProseMirror’s update cycle.

@moonrise-tk for your example, we’d probably just pass down an observable of that state if it exists outside of ProseMirror’s state.

How does your implementation of an observable work? Just a basic “subscribeToX(callback): Destructor”?.

For what its worth, I’ve found the best way to do this is just bail out of Prosemirror view plugins and instead just read a plugin’s state in a react component. Although for decorations, this is still impossible; you’ll have to put it into prosemirror’s state field.

I’ve been helping @moonrise-tk with this and I think this question could be better articulated. There’s definitely an interesting question in here:

Suppose you have a basic React app. This is typically how it would work.

function Editor() {
	const ref = useRef<HTMLDivElement | null>(null)

	useLayoutEffect(() => {
		const node = ref.current
		if (!node) return

		const state = EditorState.create({
			plugins: [],
		})

		const view = new EditorView(node, {
			state,
			plugins: [],
			dispatchTransaction(tr) {
				const nextState = this.state.apply(tr)
				view.updateState(nextState)
			},
		})
	}, [])

	return <div ref={ref}></div>
}

Now suppose, you want to have some kind of props to this component to augment the functionality of the app. I’ll call it bogusMode — it doesn’t matter what it does, but the idea is that it a global application state that augments the way prosemirror works.


function Editor({ bogusMode }) {
	const ref = useRef<HTMLDivElement | null>(null)

	useLayoutEffect(() => {
		const node = ref.current
		if (!node) return

		const state = EditorState.create({
			plugins: [
				new Plugin({
					state: {
						init() {},
						apply() {
							if (bogusMode) {
								// Do something different.
							} else {
							}
						},
					},
				}),
			],
		})

		const view = new EditorView(node, {
			state,
			plugins: [
				new Plugin({
					view() {
						return {
							update() {
								if (bogusMode) {
									// Do something different.
								} else {
								}
							},
							destroy() {},
						}
					},
				}),
			],
			dispatchTransaction(tr) {
				const nextState = this.state.apply(tr)
				view.updateState(nextState)
			},
		})
	}, [])

	return <div ref={ref}></div>
}

In a state plugin, maybe we’re keeping track of word count and when the user’s language setting changes, then we want to compute a different word count. Or in a view plugin, maybe we want to render a popup menu in a different language…

I understand that this library is not designed around React, but I think this is might be a more general problem. There’s a few ways I can imagine dealing with this but its tricky…

The most obvious thing you can do is write the props to the ProseMirror state. This makes those props available within the plugin update/apply. And I think this totally makes sense.

	useLayoutEffect(() => {
		const view = viewRef.current
		if (!view) return
		
		view.dispatch(view.state.tr.setMeta(propsKey, {bogusMode}))
	}, [bogusMode])

But things start to get a bit trickier when you think about lifting the editor state out of prosemirror and into the global application state (we’ve talked about this pattern before).

When you have some global application state, implementing a similar functional pattern as prosemirror, then you might start with something like this:

function Editor(props: {
	state: EditorState
	setState: (state: EditorState) => void
}) {
	const { state, setState } = props

	const ref = useRef<HTMLDivElement | null>(null)
	const viewRef = useRef<EditorView | null>(null)

	useLayoutEffect(() => {
		const node = ref.current
		if (!node) return

		const view = new EditorView(node, {
			state,
			plugins: [],
			dispatchTransaction(tr) {
				const nextState = this.state.apply(tr)
				setState(nextState)

				// Technically we shouldn't do this until the next re-render.
				// But this causes issues if we dispatch more than one transaction
				// in a single React render.
				view.updateState(nextState)
			},
		})

		viewRef.current = view

		return () => view.destroy()
	}, [])

	// This allows us to update the editor state from elsewhere in the app
	// and have it reflected in the editor immediately.
	useLayoutEffect(() => {
		const view = viewRef.current
		if (!view) return

		if (view.state !== state) {
			view.updateState(state)
		}
	}, [state])

	return <div ref={ref}></div>
}

One thing that’s nice about this pattern, is you can mutate the editor state from anywhere in the app. In my app, for example, when you rename a file from the sidebar, we want to update any relative links in the document and this is really simple because its a pure function of application state. renameFile(state, from, to) => state.

Now from this perspective, it’s not so simple to call view.dispatch(view.state.tr.setMeta(propsKey, {bogusMode})) because this will trigger a whole render loop… But this solution feels close.

Here’s an attempt (though I’m not happy with it):

function Editor(props: {
	bogusMode: boolean
	state: EditorState
	setState: (state: EditorState) => void
}) {
	const { state, setState, bogusMode } = props

	const ref = useRef<HTMLDivElement | null>(null)
	const viewRef = useRef<EditorView | null>(null)

	// Keep track of the editor props...
	const propsRef = useRef({ bogusMode })
	propsRef.current = { bogusMode }

	// When we render the editor, we'll apply the props to it.
	const withProps = (state: EditorState) => {
		return state.apply(state.tr.setMeta(propsKey, propsRef.current))
	}

	useLayoutEffect(() => {
		const node = ref.current
		if (!node) return

		const view = new EditorView(node, {
			// This should work fine.
			state: withProps(state),
			plugins: [],
			dispatchTransaction(tr) {
				// Ideally the global state would not have to these props.
				const nextState = this.state.apply(tr)
				setState(nextState)

				// This feels awkward.
				view.updateState(withProps(nextState))
			},
		})

		viewRef.current = view

		return () => view.destroy()
	}, [])

	useLayoutEffect(() => {
		const view = viewRef.current
		if (!view) return

		// This is even more awkward. Definitely problematic...
		if (view.state !== state) {
			view.updateState(withProps(state))
		}
	}, [state, bogusMode])

	return <div ref={ref}></div>
}

It might feel tempting to create a subscription / listener in the view plugin to react to changes in global state. And I think that would work! But this does not work in the more general case for the state plugin because the state apply function is a pure function of state…

At the end of the day, this seems like a problem of denormalization. What we ought to do, I think, is simply set bogusMode in the ProseMirror plugin state whenever we set bogusMode in the application state… I think that would solve this problem. Only downside of this approach is that it feels a bit cumbersome…

I think one step in the right direction might be to decouple init and apply for the state plugins… I think its important to be able to construct state, without any dependencies. But it would be nice to be able to define how the state evolves while incorporating the state of he rest of the application… In fact, I think we could pull out all the plugin apply methods into EditorView.props.dispatchTransaction. And that could allow this whole “props” approach to work for state plugins…

I’m still trying to digest this, but I’m curious if you have any thoughts @marijn @moonrise-tk

2 Likes

Firstly, I apologize if the first iteration of this question wasn’t super clear. I was knee-deep in this stuff, and got lazy writing up the issue I was having.

Chet: Note that with your withProps function approach, the view won’t dispatch a transaction when the props actually change. The render loop will still be tied to Prosemirror’s render loop, so it’ll only use the new prop values when a transaction gets dispatched to the view.

I also don’t think I agree with using props in the state.apply function. Props are really just state from higher up in the tree, so using state to compute other state gets a little confusing…Ideally state is different independent pieces that you can read from while creating the view.

Here are some diagrams that I made to try and illustrate the issue I’m having.

This is Prosemirror’s typical loop.

This is Prosemirror tied into a bigger system, and the problem I’m having. I can’t pass props i.e. app state (or app services, but I didn’t show them here) into editor plugin views.

You commonly need other parts of the app state sometimes to render correctly. Like state about the theme colors, or global user font preference, etc.

Here are four possible options to fix the problem.

You can use a “props” plugin which goes into the editor state, and you just have to make sure to update it every time the app state changes.

The second option is to just lift plugin views out of prosemirror and just make it your app view’s responsibility. Now you just read the plugin state through the editor state and use whatever services you need. Basically you make it the App’s responsibility to render the plugin views.

This is, imo, the “right” approach. In an ideal world, you shouldn’t even need the EditorView component when integrating Prosemirror into a bigger system. It gets replaced by the App view and EditorState gets rendered as part of it. This approach heads toward that direction; it makes some plugin views part of the app view. This could look like, for example, just making plugin views React components that render with the natural flow of your app.

The third approach is using observables/subscriptions like cole suggested or in my initial post. I didn’t bother to figure out how to draw this, but in mount you pass in those subscriptions as the “initial” props to the Editor View.

The fourth approach is inventing some new concept in Prosemirror to support this. I’m not really sure what it would look like; Maybe the EditorView becomes a function of state and props where props is any object.

I hope this clarifies the issue I’m having. There’s another part to this issue, which is Decorations and Nodeviews. Decorations are a pure function of state, and node views also just manage everything in a component, so its the same issue with the Plugins. The complication is that approach 2 is impossible; In the case of plugins, plugin views are rendered completely separately from the dom element of the editor (like popups), so you can simply move their rendering out of a plugin view. But decorations and node views are part of the document rendering element.

2 Likes