Safari input lag with focusable elements inside block replacement widgets

In Safari 17.6, typing before CodeMirror block replacement widgets becomes noticeably slow when the widgets contain native focusable elements such as .

Minimal reproduction: create 2–3 Decoration.replace({block: true, widget}) decorations. If each widget contains a static header and a <pre>, editing remains smooth. Adding two <button> elements to each widget causes noticeable input lag when pressing Enter near the start of the document. Removing the buttons immediately resolves it. The same demo remains smooth in Chrome.

This reproduces with both CodeMirror drawSelection() and the native Safari selection, and without ResizeObserver, estimatedHeight, or manual requestMeasure() calls. It appears to be a Safari/WebKit interaction between contenteditable, block widgets, and focusable descendants.

Since you specifically mention an older Safari, does that mean the issue doesn’t occur in never versions?

I don’t know, because I only have an old browser, and I found this issue in the old browser. I’m running macOS 14.8.3 (build 23J220).

I can’t reproduce this with this widget. Would be useful if you can condense what you’re doing down to a minimal script.

Okay, I’ve made a demo, as follows:

The top-right corner can display the time taken from pressing Enter to the save being completed.

The source code:

import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { syntaxTree } from "@codemirror/language";
import { Compartment, EditorState, StateField } from "@codemirror/state";
import { Decoration, drawSelection, EditorView, keymap, WidgetType } from "@codemirror/view";
import { GFM } from "@lezer/markdown";

const TEST_DOCUMENT = `Press Enter repeatedly at the top of this document and compare the widget modes.









\`\`\`js
const first = true;
console.log(first);
\`\`\`

Regular text after the first code block.

\`\`\`python
second = 2
print(second)
\`\`\`

Regular text after the second code block.

\`\`\`lua
local third = 3
print(third)
\`\`\`

Regular text after the third code block.

\`\`\`css
.fourth { color: blue; }
\`\`\`

Regular text after the fourth code block.

\`\`\`html
<p>Fifth code block</p>
\`\`\`

Regular text after the fifth code block.

\`\`\`json
{"block": 6}
\`\`\`

Regular text after the sixth code block.

\`\`\`bash
echo "Seventh code block"
\`\`\`

Regular text after the seventh code block.

\`\`\`typescript
const eighth: number = 8;
\`\`\`

End of document.
`;

const modeCompartment = new Compartment();
const selectionCompartment = new Compartment();
const status = document.querySelector("#status");
const modeSelect = document.querySelector("#mode");
const selectionModeSelect = document.querySelector("#selectionMode");
const sharedToolbar = document.querySelector("#sharedToolbar");
const sharedLanguage = document.querySelector("#sharedLanguage");
const sharedCopy = document.querySelector("#sharedCopy");
const sharedLanguageMenu = document.querySelector("#sharedLanguageMenu");
let lastInputAt = 0;
let activeSharedBlock = null;
const LANGUAGE_NAMES = ["TEXT", "JAVASCRIPT", "TYPESCRIPT", "JSON", "LUA", "HTML", "CSS", "PYTHON", "SHELL", "SQL", "MERMAID", "SVG"];

for (const name of LANGUAGE_NAMES) {
  const option = sharedLanguageMenu.appendChild(document.createElement("button"));
  option.type = "button";
  option.textContent = name;
  option.addEventListener("click", () => {
    sharedLanguage.textContent = name;
    sharedLanguageMenu.style.display = "none";
  });
}

function hideSharedToolbar() {
  sharedToolbar.classList.remove("is-visible");
  sharedLanguageMenu.style.display = "none";
  activeSharedBlock = null;
}

function positionSharedToolbar(block) {
  if (!block?.isConnected || modeSelect.value !== "shared") return hideSharedToolbar();
  activeSharedBlock = block;
  const rect = block.getBoundingClientRect();
  sharedLanguage.textContent = block.dataset.language || "TEXT";
  sharedToolbar.classList.add("is-visible");
  const width = sharedToolbar.offsetWidth;
  sharedToolbar.style.left = `${Math.max(8, rect.right - width - 8)}px`;
  sharedToolbar.style.top = `${Math.max(8, rect.top + 5)}px`;
}

class SimpleCodeWidget extends WidgetType {
  constructor(source) { super(); this.source = source; }
  eq(other) { return other.source === this.source; }
  toDOM() {
    const pre = document.createElement("pre");
    pre.className = "simple-code";
    pre.textContent = this.source;
    return pre;
  }
}

class ComplexCodeWidget extends WidgetType {
  constructor(source, language, variant) {
    super();
    this.source = source;
    this.language = language;
    this.variant = variant;
  }
  eq(other) { return other.source === this.source && other.language === this.language && other.variant === this.variant; }
  toDOM() {
    const shell = document.createElement("div");
    shell.className = "demo-code-shell";
    const section = shell.appendChild(document.createElement("section"));
    section.className = "demo-code";
    const header = section.appendChild(document.createElement("header"));
    const staticHeader = this.variant === "static" || this.variant === "shared";
    const language = header.appendChild(document.createElement(staticHeader ? "span" : "button"));
    language.textContent = this.language || "TEXT";
    let menu = null;
    if (!staticHeader) {
      language.type = "button";
      const copy = header.appendChild(document.createElement("button"));
      copy.type = "button";
      copy.textContent = "Copy";
      copy.addEventListener("click", () => navigator.clipboard?.writeText(this.source));
    }
    if (this.variant === "complex") {
      menu = document.body.appendChild(document.createElement("div"));
      menu.className = "demo-menu";
      for (const name of LANGUAGE_NAMES) {
        const option = menu.appendChild(document.createElement("button"));
        option.type = "button";
        option.textContent = name;
        option.addEventListener("mousedown", (event) => event.preventDefault());
      }
      language.addEventListener("click", () => {
        const rect = language.getBoundingClientRect();
        menu.style.left = `${rect.left}px`;
        menu.style.top = `${rect.bottom + 5}px`;
        menu.style.display = menu.style.display === "block" ? "none" : "block";
      });
    }
    const pre = section.appendChild(document.createElement("pre"));
    const code = pre.appendChild(document.createElement("code"));
    code.textContent = this.source;
    if (this.variant === "shared") {
      section.dataset.language = this.language || "TEXT";
      section.dataset.source = this.source;
      section.addEventListener("mouseenter", () => positionSharedToolbar(section));
      section.addEventListener("mousedown", () => positionSharedToolbar(section));
    }
    shell._cleanup = () => menu?.remove();
    return shell;
  }
  destroy(dom) { dom._cleanup?.(); }
}

function codeDecorations(state, variant) {
  const ranges = [];
  syntaxTree(state).iterate({
    enter(node) {
      if (node.name !== "FencedCode") return;
      const first = state.doc.lineAt(node.from);
      const last = state.doc.lineAt(Math.max(node.from, node.to - 1));
      if (state.selection.ranges.some((range) => range.from >= node.from && range.to <= node.to)) return false;
      const from = first.to < node.to ? first.to + 1 : first.to;
      const to = last.from > from ? last.from - 1 : from;
      const source = state.doc.sliceString(from, to);
      const language = first.text.replace(/^\s*`{3,}/, "").trim();
      ranges.push(Decoration.replace({
        widget: variant === "simple" ? new SimpleCodeWidget(source) : new ComplexCodeWidget(source, language, variant),
        block: true,
      }).range(node.from, node.to));
      return false;
    },
  });
  return Decoration.set(ranges, true);
}

function codeBlocks(variant) {
  return StateField.define({
    create(state) { return codeDecorations(state, variant); },
    update(value, transaction) {
      if (!transaction.docChanged && !transaction.selection) return value;
      return codeDecorations(transaction.state, variant);
    },
    provide: (field) => [
      EditorView.decorations.from(field),
      EditorView.atomicRanges.of((view) => view.state.field(field)),
    ],
  });
}

function modeExtension(mode) {
  if (["complex", "buttons", "shared", "static", "simple"].includes(mode)) return codeBlocks(mode);
  return [];
}

const nativeSelectionTheme = EditorView.theme({
  "& .cm-selectionLayer": { display: "none !important" },
});

function selectionExtension(mode) {
  return mode === "native" ? nativeSelectionTheme : drawSelection();
}

const editor = new EditorView({
  parent: document.querySelector("#editor"),
  state: EditorState.create({
    doc: TEST_DOCUMENT,
    extensions: [
      history(),
      markdown({ extensions: [GFM] }),
      EditorView.lineWrapping,
      modeCompartment.of(modeExtension("complex")),
      selectionCompartment.of(selectionExtension("draw")),
      EditorView.updateListener.of((update) => {
        if (!update.docChanged) return;
        const now = performance.now();
        const elapsed = lastInputAt ? now - lastInputAt : 0;
        status.textContent = `${modeSelect.value} · update ${elapsed.toFixed(1)} ms`;
        lastInputAt = now;
      }),
      keymap.of([...defaultKeymap, ...historyKeymap]),
    ],
  }),
});

modeSelect.addEventListener("change", () => {
  hideSharedToolbar();
  editor.dispatch({ effects: modeCompartment.reconfigure(modeExtension(modeSelect.value)) });
  editor.focus();
});

sharedLanguage.addEventListener("click", () => {
  if (!activeSharedBlock) return;
  const rect = sharedLanguage.getBoundingClientRect();
  sharedLanguageMenu.style.left = `${rect.left}px`;
  sharedLanguageMenu.style.top = `${rect.bottom + 5}px`;
  sharedLanguageMenu.style.display = sharedLanguageMenu.style.display === "block" ? "none" : "block";
});

sharedCopy.addEventListener("click", () => {
  if (activeSharedBlock) navigator.clipboard?.writeText(activeSharedBlock.dataset.source || "");
});

window.addEventListener("resize", () => activeSharedBlock && positionSharedToolbar(activeSharedBlock));
editor.scrollDOM.addEventListener("scroll", () => activeSharedBlock && positionSharedToolbar(activeSharedBlock), { passive: true });

selectionModeSelect.addEventListener("change", () => {
  editor.dispatch({ effects: selectionCompartment.reconfigure(selectionExtension(selectionModeSelect.value)) });
  editor.focus();
});

document.querySelector("#reset").addEventListener("click", () => {
  editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: TEST_DOCUMENT } });
  editor.focus();
});

window.demoEditor = editor;

demo.html (500.5 KB)

That demo displays the time between the last document change and the new one. I’m not sure how that is useful for measuring editor latency.

For example, when you repeatedly press the Enter key at the beginning of the editor, the blocks below will be re-rendered. This allows you to calculate the time required to render these blocks—the more time it takes, the more laggy it is. The difference is not very noticeable in Chrome, but it is much more apparent in Safari.

Here, “continuous pressing” means that after you press the Enter key, you wait for the new cursor to appear on the next line before pressing Enter again. If it stays stuck and doesn’t appear or takes too long to appear, that indicates lag.

The reason I use this method for testing is that when I first started using it normally, I noticed that pressing Enter at the beginning of the editor would cause a long lag, and the more blocks there were at the bottom, the more noticeable it became. If I was at the bottom, or if there were no blocks or buttons, the lag would not occur.

You mean rendered by the browser? Because the the widgets in your demo are not being redrawn.

I still cannot reproduce any noticeable slowness in the demo, if I set up a direct benchmark (updating the content, forcing a redraw, and checking the time that actually takes to do that a lot of times)—I get about 1.2ms in Safari, which is slower than the 0.5ms Firefox takes, but not something that the user can notice.

I’m not sure whether this is a rendering issue or something else. However, in my testing on my computer, after pressing Enter once, it takes more than 1 second for the cursor to reappear on the next line. Please see the screen recording demonstration for details (you can hear the keyboard sounds: I pressed Enter quickly 5 times, but the cursor does not appear on the next line until 1 second later). This does not happen when I use Chrome; it usually takes around 200 milliseconds.

The demo video is as follows: