블로그 · engineering
X's composer is Draft.js, and setting textContent breaks it. The three insertion paths ReplyKit uses, how it builds threads by clicking the +, and why it never streams into the editor.
ReplyKit drafts a reply, you pick one, and it lands in the reply box on X ready to edit. That last step sounds like the easy part. It took more iterations than the prompting did, because X's composer is not a text field. It is a Draft.js editor, and Draft.js does not care what you put in its DOM.
This post is the state of that code as of October 2026. All of it lives in one file, src/adapters/x.ts, behind a small adapter interface so the rest of the extension never knows which site it is on.
The obvious move is to find the editable node and set its text. The node is easy to find: X marks it with data-testid="tweetTextarea_0" and the Draft.js class public-DraftEditor-content. Then:
editor.textContent = draft;
editor.dispatchEvent(new Event("input", { bubbles: true }));The text appears. The character counter stays at 0, the Post button stays disabled, and the next React render wipes it. Draft.js keeps its own model of the content and rebuilds the DOM from that model. What you wrote was never in the model.
So the job is not "put text in the DOM". It is "make Draft.js believe the user typed this". There are three ways to do that, and ReplyKit tries them in order.
Draft.js listens for paste and reads clipboardData off the event. A content script can build that event itself. It does not need clipboard permission, because nothing touches the real clipboard; the DataTransfer is constructed in memory.
function pasteInto(editor: HTMLElement, text: string): boolean {
try {
const dt = new DataTransfer();
dt.setData("text/plain", text);
editor.focus();
selectAllIn(editor); // replace, don't append
return !editor.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
})
);
} catch {
return false; // no DataTransfer here (old engines, jsdom)
}
}Two details carry this function. The first is selecting the editor's contents before dispatching, so a second insert replaces the first draft instead of stacking under it. The second is the return value. dispatchEvent returns false when a listener called preventDefault(). Draft.js does exactly that when it consumes a paste. So a false means the editor took the text, and a true means nobody handled it and we have to fall back. No polling, no timeout.
This path is the good one. The counter updates, the Post button enables, and line breaks become real Draft.js blocks, which matters for multi-line posts. It works on every current Chromium build we have seen.
document.execCommand is deprecated and still everywhere. insertText fires the beforeinput flow that Draft.js also listens to, so it is a second native route into the model.
function execInsert(editor: HTMLElement, text: string): boolean {
if (typeof document.execCommand !== "function") return false;
editor.focus();
selectAllIn(editor);
return (
document.execCommand("insertText", false, text) &&
(editor.textContent || "").includes(text.slice(0, 8))
);
}The return value of execCommand is not trustworthy on its own, so the function also checks that the first few characters actually landed. It is a fallback and in practice the paste path catches almost everything before this runs.
If neither event is consumed, the last resort is to write the DOM in the shape Draft.js expects: a data-contents wrapper, one data-block per line, each with the data-offset-key Draft.js uses to map DOM back to its model, then a burst of input, textInput and keyup events to make it look.
text.split("\n").forEach((line, i) => {
const key = `${editorId}-${i}-0`;
const block = el("div", { "data-block": "true", "data-offset-key": key });
const inner = el("div", {
"data-offset-key": key,
class: "public-DraftStyleDefault-block public-DraftStyleDefault-ltr",
});
const span = el("span", { "data-offset-key": key });
span.textContent = line;
inner.append(span); block.append(inner); contents.append(block);
});One block per line is the point of this path. A single text node would collapse a four-line post into one paragraph. Even so, this is the path we least want to run: the editor may or may not pick the content up into its model, and the next re-render decides. It exists so the Insert button does something visible when X changes its event handling, and the panel falls back to copying the text if even this fails.
A single X page can hold three composers at once: the home box at the top of the feed, an inline reply box under a post, and the modal that opens when you click Reply. Each of them is tweetTextarea_0. Picking the first match inserts your reply into the wrong place about a third of the time.
ReplyKit mounts its panel next to the composer the user is working in, so the rule is: of all the editors on the page, take the one closest in the DOM to our own panel. When the reply modal is open, the panel inside the dialog wins, which is what you want.
A thread is several tweet boxes in one composer. You get a new one by clicking the "+" button. Three things about that button made this the slowest part to get right.
It does not exist until the previous box has content. X renders and enables the "+" after a React re-render that follows the insertion. Checking for it before inserting gives up too early. Clicking it once right after inserting often lands while it is still disabled. So the code inserts first, then polls: look for box i, and if it is not there and the composer still has only i boxes, click the "+" if it is enabled, sleep 150 ms, repeat for up to five seconds. The "only click while the box count is still i" guard is what prevents a slow render from producing two empty tweets.
It ignores click(). X's toolbar buttons act on pointerdown and mousedown, so they do not steal focus from the editor. A bare .click() dispatches only click, and nothing happens. The fix is to fire the whole sequence at the real interactive element:
function simulateClick(el: HTMLElement): void {
const target = el.closest("button, [role=button]") ?? el;
const opts = { bubbles: true, cancelable: true, view: window };
target.dispatchEvent(new PointerEvent("pointerdown", opts));
target.dispatchEvent(new MouseEvent("mousedown", opts));
target.dispatchEvent(new PointerEvent("pointerup", opts));
target.dispatchEvent(new MouseEvent("mouseup", opts));
target.dispatchEvent(new MouseEvent("click", opts));
}Its test id and label are not stable. data-testid="addButton" has been renamed before, and aria-label changes with the UI language. The SVG path of the plus icon has not changed, so as a last lookup the code finds the button by the first characters of that path's d attribute. It is an ugly selector and it has outlived the pretty ones.
When a step fails, the adapter logs one line listing every editor and button it can see in the composer and whether the plus glyph is present. X ships DOM changes without notice, and that one line has turned "threads are broken" into a five-minute fix more than once.
Drafts stream in token by token. The tempting demo is to watch them type themselves into X's reply box. We tried it and took it out.
A synthetic paste per chunk means dozens of paste events a second into an editor that re-renders on each one. It fights the user's cursor, it fills the undo stack with garbage, and it leaves X's own draft-saving in a strange state if the user navigates away mid-stream. It also means three drafts cannot stream at once, and three drafts per click is the product.
So the architecture is: the background service worker does the fetch (content scripts are subject to the page's CORS and CSP, the worker is not), parses the server-sent events, and relays the cumulative text to the tab with a requestId. The content script renders that into its own panel, a plain textarea it controls completely, and drops any chunk whose request id is not the current one so a fast second click cannot interleave two generations. The Draft.js code above runs exactly once, when the user picks a draft and presses Insert.
dispatchEvent return value is the acknowledgement.The whole adapter, with the LinkedIn and Reddit ones beside it, is on GitHub. If you maintain an extension that writes into X and have hit a case this misses, I would like to hear about it.
만든 사람 · 한 사람
뒤에 회사는 없어요. 메시지를 하나하나 다 읽는 개발자 한 명뿐이에요. ReplyKit이 내 작업 방식과 안 맞는다면 기능이든 플랫폼이든 다른 답글 스타일이든 말해 주세요. 다음에 뭘 내놓을지는 사용자 요청으로 정해져요.
이메일이 편한가요? contact@replykit.eu