Five rich-text editors, five ways to inject text, and the layer that actually matters
The DOM is not the source of truth. That sounds obvious, and it is the thing that breaks almost every browser agent that edits text on a page.
The field fills, the save endpoint answers 200, and the value stored is empty. I hit this five times in one day on five different editors, and the fix was always the same idea: find the layer the submit handler reads from, and write to that one.
Three layers, only one of which is authoritative
Most text inputs on the modern web are not the node you can see. They are a stack:
- the DOM - what is rendered
- the editor document - ProseMirror state, Quill's internal model, Draft.js ContentState
- the application store - the React or Redux state that builds the request body
element.value = ..., innerText = ... and document.execCommand(...) touch layer 1, or reach layer 2 by accident. The submit handler serialises layer 3. When nothing propagates upward, you get a clean, successful no-op.
What follows is what worked on each editor. All of it was found the slow way.
1. ProseMirror: beforeinput, not execCommand
ProseMirror listens for the beforeinput event and mutates its document in response to it. It does not read what you write into the DOM.
const el = document.querySelector('.ProseMirror');
el.focus();
const ev = new InputEvent('beforeinput', {
inputType: 'insertText',
data: text,
bubbles: true,
cancelable: true,
composed: true,
});
el.dispatchEvent(ev);
document.execCommand('insertText') and driver-level typing both produced the same result here: the text appeared in the field, Save returned success, and the stored document was one empty paragraph.
2. Tiptap: a real paste payload
Tiptap is ProseMirror underneath, but its paste handler is the part you can reach. Dispatch a synthetic clipboard event carrying a real DataTransfer:
const dt = new DataTransfer();
dt.setData('text/plain', text);
const consumed = !editorEl.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
);
Check consumed. dispatchEvent returns false only when a handler called preventDefault(), so that boolean is your proof the editor took the payload rather than the browser shrugging at it.
3. Quill: execCommand works, and it multiplies newlines
Quill ignored the synthetic paste entirely but accepted this:
editorEl.focus();
document.execCommand('insertText', false, text);
One quirk cost me a visible edit: Quill expands paragraph breaks, so two newlines in came back as four blank lines out. Author with single newlines and re-read the result before publishing.
4. Draft.js: only trusted input, and innerText lies
Draft.js (X's composer) keeps its text in its own render nodes:
const stateText = composer.querySelector('span[data-text="true"]').textContent;
Use that as the oracle, not innerText. After an execCommand write, Draft.js leaves a sibling text node that React never reconciles, so innerText double-counts and the character counter reports a number that is not true of the state. I published a truncated post believing that counter.
Synthetically, nothing works on Draft.js: beforeinput is ignored, and a synthetic paste is consumed but inserts nothing. Only a real browser-level keystroke updates its state.
5. The case that broke my own rule: editor state was also the wrong layer
I was ready to end this article at write to the editor state. Then a Tiptap composer that mirrors itself into a Redux store refused to publish. I set content through the editor API and verified everything I thought mattered: the document held 1,813 characters, the update event fired, the DOM rendered the text.
The request body the app sent was:
{ "caption": "", "postTitle": "Automated text entry is a state problem, not a DOM problem" }
Empty caption. The same text delivered through the clipboard paste route in section 2 reached the store, and the identical payload published at 200. Same visible result, one layer apart. Layer 2 was not authoritative either.
Verify against the payload, not the page
Hook the network before diagnosing anything:
window.__cap = [];
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, u) {
this.__u = u;
this.__m = m;
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (b) {
window.__cap.push({ url: this.__u, method: this.__m, body: b });
return send.apply(this, arguments);
};
Then assert exactly one of three things: the outgoing request body contains your text; the framework document contains it (editor.state.doc.textBetween(...), editor.getHTML()); or a cold reload of the page shows it. The reload is the only proof that reaches layer 3.
Two smaller findings that cost real time
A disabled submit button is usually a validation message that moved somewhere else. In the Peerlist composer, the server's 400 string was bound to the button's disabled prop, so an over-length caption presented as a permanently dead button plus a generic error. The actual limit - 480 characters - only appeared as helper text once the content was already short enough. No counter, no warning while you are over it.
On React-controlled inputs, input.value = x does nothing, because React reads through its own descriptor and overwrites you on the next render:
const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
set.call(ta, text);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
The part that is not a trick
Every editor in this list is behaving correctly. The bug is the assumption that what you can see is what gets saved. That assumption survives for a long time, because the failure is quiet: the UI looks right the whole way through, and the only artefacts that tell the truth are the request the app sends and the page you load ten minutes later.
I build ZANCTA, a set of local-first PDF and image tools that run entirely in the browser tab, so the general shape of this problem is familiar to me: if the state you are reading is a copy of the state that matters, you will be confidently wrong.
Comments
No comments yet. Start the discussion.