A declarative API for using promises in prosemirror plugins.
# npm
npm install prosemirror-async-query
# yarn
yarn add prosemirror-async-query
This documentation assumes you have some familiarity with prosemirror.
If you do not know about Prosemirror Plugins, in particular, methods such as view.update
, state.apply
, and state.init
then you may want to start by reading the Prosemirror Guide.
In the normal prosemirror data flow, the "editor displays editor state, and when something happens, it creates a transaction and broadcasts this. This transaction is then, typically, used to create new state, which is given to the view using its update state method" 1.
The naive way to add promises to this data flow is to set up the promise in state.apply()
and then await
the promise in view.update()
.
This pattern shows up in the real world but it has a couple of issues.
- Because the view only updates when the promise returns, the user is unaware that anything is happening until the promise returns. This could be a usability issue if the promise takes longer than a couple of milliseconds.
- If a transaction leads to a new promise, you may want to cancel the currently running promise, but creating communication between a
state.apply()
and a previousview.update()
goes against the normal prosemirror data flow.
prosemirror-async-query
enables easy integration of promises in prosemirror plugins by exposing a declarative API for keeping track of promise state using transactions and view updates.
To start you create a query in state.apply()
and add it to your plugin state.
At the very least you must provide a query
function that returns a promise that resolves data or throws an error.
import { AsyncQuery } from "prosemirror-async-query";
import { Plugin } from "prosemirror-state";
const plugin = new Plugin({
state: {
init() {
return { query: null };
},
apply(tr, prev) {
// if the query does not exist create it.
if (prev.query === null) {
return {
query: new AsyncQuery({ query: fetchTodos }),
};
}
return prev;
},
},
});
Having a query in your plugin state does not do anything yet.
The next step is to run the query using the query's viewUpdate
and viewDestroy
methods.
These are meant to be called in the plugin's view.update()
and view.destroy()
methods respectively.
import { AsyncQuery } from "prosemirror-async-query";
import { Plugin, PluginKey } from "prosemirror-state";
const plugin = new Plugin({
// use a plugin key so we can access plugin state
key: new PluginKey("async-query-plugin"),
view() {
update(editor) {
// run the query update method
pluginKey.getState(editor.state)?.query?.viewUpdate(editor);
},
destroy() {
// run the query destroy method
pluginKey.getState(editor.state)?.query?.viewDestroy(editor);
}
},
state: {
init() {
return { query: null };
},
apply(tr, prev) {
if (prev.query === null) {
return {
query: new AsyncQuery({query: fetchTodos}),
};
} else {
// check the query status (we'll improve this in a second)
console.log(prev.query.status)
}
return prev;
},
},
});
Before you continue it is helpful to learn what the query.status
means.
idle
means the query exists but the query function has not been run or the query is not enabled (learn more about enabled in the source code comments).loading
means the query is fetching but has not returned yet.error
means the query was canceled or encountered an error.success
means the query returned successfully and has data.
When you create a new query with AsyncQuery()
the query has a status of idle
.
When you call viewUpdate
on a query, the method checks to see if the query has an idle
status.
If the query status is idle
the viewUpdate
method runs the query, updates the query status to loading
, and dispatches a transaction indicating that the query status is loading
.
If the query function returns successfully viewUpdate
sets the query status to success
and dispatches a transaction indicating that the query status is success
.
If the query function throws an error or is canceled, viewUpdate
sets the query status to error
and dispatches a transaction indicating that the query status is error
.
Because viewUpdate
dispatches transactions whenever the queryStatus
changes, you can handle any changes to the query
in state.apply()
.
In the example we access the query
status in state.apply()
but the example has an issue.
There are many transactions and we probably only want to react to query status changes once, not on every transaction.
Luckily AsyncQuery
makes this easy using the statusChanged
method.
import { AsyncQuery } from "prosemirror-async-query";
import { Plugin, PluginKey } from "prosemirror-state";
const plugin = new Plugin({
key: new PluginKey("async-query-plugin"),
view() {
update(editor) {
pluginKey.getState(editor.state)?.query?.viewUpdate(editor);
},
destroy() {
pluginKey.getState(editor.state)?.query?.viewDestroy(editor);
}
},
state: {
init() {
return { query: null };
},
apply(tr, prev) {
if (prev.query === null) {
return {
query: new AsyncQuery({query: fetchTodos}),
};
// check if the query status changed
} else if (prev.query.statusChanged(tr)) {
console.log("query status changed", prev.query.status);
}
return prev;
},
},
});
The statusChanged
method only returns true for transactions dispatched by the query's viewUpdate
method.
We now have a declarative API for defining an asynchronous function in state.apply()
, running the asynchronous function in view.update()
, and reacting to changes in the asynchronous function in state.apply()
.
As a final tip, both statusChanged
and viewUpdate
take flags which can help control the data flow.
For example, you may only want to handle status changes when the query has returned successfully.
You can filter transaction in statusChanged
to only return true for specific statuses by passing a status
value as the second argument to the function.
if (query.statusChanged(tr, "success")) {
console.log(query.status === "success");
}
if (query.statusChanged(tr, ["success", "error"])) {
console.log(query.status === "success" || query.status === "error");
}
However you can also avoid dispatching extra transactions altogether by passing ignore
flags to viewUpdate
.
// the query will only dispatch a transaction when the query returns successfully
pluginKey.getState(editor.state)?.query?.viewUpdate(editor, { ignoreLoading: true, ignoreError: true });
Here is an end to end example with more controlled data flow.
import { AsyncQuery } from "prosemirror-async-query";
import { Plugin, PluginKey } from "prosemirror-state";
const plugin = new Plugin({
key: new PluginKey("async-query-plugin"),
view() {
update(editor) {
pluginKey.getState(editor.state)?.query?.viewUpdate(
editor,
// don't send transactions for queries that are loading or errored
{ignoreLoading: true, ignoreError: true}
);
},
destroy() {
pluginKey.getState(editor.state)?.query?.viewDestroy(editor);
}
},
state: {
init() {
// added a query result to our state
return { query: null, result: null };
},
apply(tr, prev) {
if (prev.query === null) {
return {
query: new AsyncQuery({query: fetchTodos}),
};
// only handle the success case
} else if (prev.query.statusChanged(tr, "success")) {
console.log("query returned successfully");
return {
...prev,
result: query.data,
}
}
return prev;
},
},
});
For more in depth documentation check out the comments in the source code as well as the example usage from the demo, and feel free to open an issue if you have any questions!
Footnotes
-
quoted from the prosemirror guide. ↩