debouncing / merging docChanged transactions?

CodeMirror 6 lets you listen for doc changes in the form of transactions, but every change is fired off immediately: if a user types function cakeBaker(type="extreme") at a normal typing rate, that’s 30+ transactions for one letter each, instead of a single transaction for the single stretch of text typed.

Is there a way to set a debounce interval (“keep aggregating changes until there’s no new change for X milliseconds”) or even debounce with explicit “ignore debounce if …” rule (e.g. don’t finalize the current transaction unless the change was a boundary character), or even a way to bundle/merge transactions into one transaction after the fact, so that someone can roll their own debounce logic that lets them send a single object to their OT server ? (e.g. const bundle = Transaction.merge(t1,t2,t3,t4,t5,...)).

You cannot merge transactions, but you can merge change set with ChangeSet.compose. You could set up a thing that accumulates changes and runs some code on the result only after a given period of inactivity.

1 Like

Sounds good! is there an example or docs page that illustrates how to do that somewhere?

Won’t work for everyone but I get an effect like this by calling code from the linter callback.

import { linter, lintGutter } from "@codemirror/lint";
// other eslint stuff I guess not strictly necessary
// but see https://codemirror.net/examples/lint/
// in extensions:

    linter(
      view => {
        let o = esLint(new eslint.Linter(), config)(view).filter(d => !(d.source == 'eslint:no-undef' && d.message.includes("_in'")));
        if (o.length == 0) {
          update(view); // update is my custom function
        }
        return o;
      }),

I think I read that the linter pass gets skipped if it interferes with editor responsiveness, so not quite a simple debounce, but it might have interesting code to look into.

1 Like

My “solution” was to have a debounce listener on document changes and just literally diff the document with its previous version. That’s probably a really bad plan for huge documents, but for my use-case it works quite well:

function getInitialState(filename, data) {
  const doc = data.toString();
  const extensions = [basicSetup];

  // ...

  // Add debounced content change syncing
  extensions.push(
    EditorView.updateListener.of((e) => {
      if (e.docChanged) {
        const entry = cmInstances[filename];
        if (entry.debounce) {
          clearTimeout(entry.debounce);
        }
        entry.debounce = setTimeout(() => syncContent(filename), 1000);
      }
    })
  );

  return EditorState.create({ doc, extensions });
}

with the sync function being fairly boilerplate:

async function syncContent(filename) {
  const entry = cmInstances[filename];
  // get the old and new content
  const currentContent = entry.content;
  const newContent = entry.view.state.doc.toString();
  // form a diff and send that over to the server
  const changes = createPatch(filename, currentContent, newContent);
  const response = await fetch(`/sync/${filename}`, {
    headers: { "Content-Type": `text/plain` },
    method: `post`,
    body: changes,
  });
  // verify both client and server have the same content now
  const responseHash = parseFloat(await response.text());
  if (responseHash === getFileSum(newContent)) {
    entry.content = newContent;
    updatePreview();
  } else {
    // This should, if I did everything right, never happen.
  }
  entry.debounce = false;
}

(with createPatch being the standard “get me the diff between these two strings” that you can find loads of libraries for)

1 Like