Three hours of a recent debugging session went into a single wrong assumption: that the editor I was automating lived in the same document as the page I had loaded.

document.querySelectorAll('[contenteditable]') returned an empty array. Not a stale reference, not a shadow root, not a timing problem. An empty array, on a page that visibly had a rich text editor in the middle of it.

The editor was in a blob iframe

Modern block editors isolate their content styles inside an <iframe> whose src is a blob: URL, generated at runtime. WordPress block editor does this. So does the Tumblr editor, which shares the same lineage. The point is CSS isolation: the editor can load a theme stylesheet without leaking into the host page, and the host page cannot break the editor.

The consequence for automation is that the editable nodes are in a different document. querySelector on the top document will never find them, no matter how long you wait.

Find it like this:

const frame = [...document.querySelectorAll("iframe")].find(f => f.offsetWidth > 600); const doc = frame.contentDocument; // same-origin, so this works const title = doc.querySelector('h1[aria-label="Add title"]'); 

Two details matter. First, filter by size, because the same page usually carries several zero-size iframes for analytics, cookie checks and REST proxies. Second, contentDocument works here even though the src says blob: - blob URLs inherit the origin of the page that created them, so this is a same-origin document, not a cross-origin one.

Clicks need coordinate math, not a selector

Hit-testing a click is done against the top-level viewport. If your automation framework only accepts screen coordinates, you have to add the iframe offset yourself:

const fr = frame.getBoundingClientRect(); const r = doc.querySelector('p[aria-label="Add default block"]').getBoundingClientRect(); const x = fr.x + r.x + 30; const y = fr.y + r.y + r.height / 2; await page.mouse.click(x, y); 

The +30 is not decoration. Clicking the exact left edge of a text block in these editors often lands in the block toolbar gutter instead of the text. A few tens of pixels inside the box is the difference between typing and doing nothing.

Keyboard events follow focus, so they just work

The pleasant part: once the click lands, keyboard.type() types into the iframe with no extra handling. Focus determines the target, and focus crosses document boundaries fine. So the split is: finding and clicking need the iframe, typing does not.

That asymmetry is worth internalising, because it also applies to paste. A ClipboardEvent dispatched on the focused editable inside the iframe reaches the editor and is handled normally - which is often a much faster way to populate a long document than typing it out:

const dt = new DataTransfer(); dt.setData("text/html", html); dt.setData("text/plain", plain); const handled = target.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); // handled === false means the editor called preventDefault, i.e. it took the content 

A short checklist

  • Editor missing from the DOM? List every iframe with its size before assuming the editor failed to load.
  • contentDocument is null? Then it really is cross-origin and you need frame APIs, not DOM access.
  • Coordinates off? Offset by the frame rect, and click inside the box rather than on its edge.
  • Text going nowhere? Check document.activeElement inside the frame, not in the host document.

None of this is difficult once you know it. The expensive part was assuming the page had one document when it had two.