Skip to content

Trigger menu can silently delete part of a block's text (two independent causes) #159

Description

@Bo-Feng-1024

Summary

When a workflow is launched from the input trigger menu (default jj) on a block that already contains text, SmartBlocks can permanently remove a span of that text. There are two independent causes; either one is sufficient.

Both causes are specific to the trigger-menu path; the command palette invokes sbBomb with offsets that make the splice a no-op (see below). I would not go so far as to call the palette path safe — I have seen one unrelated and so far unexplained failure there — but neither of the two mechanisms described here applies to it.

Impact

sbBomb strips the trigger out of the block by splicing prefix + suffix back together. When the offsets are wrong, the splice removes real content, and the loss is silent — nothing throws, and the workflow appears to run normally.

Two consequences:

  1. The block loses text permanently (undo is the only recovery, if the user notices in time).
  2. prefix is also stored as introContent, which seeds smartBlocksContext.currentContent. So <%CURRENTBLOCKCONTENT%> returns the same truncated string, and any workflow that wraps existing text propagates the corruption into its output.

Workflows that insert a template into an empty block are unaffected — prefix and suffix are both empty, so there is nothing to lose. The workflows at risk are the ones that wrap text the user already typed, which is exactly the documented use case for <%CURRENTBLOCKCONTENT%>.

Cause 1 — offsets come from the textarea, the string comes from the database

src/SmartblocksMenu.tsx, onSelect:

const currentTextarea = document.getElementById(textarea.id) as HTMLTextAreaElement;
waitForBlock(blockUid, textarea.value).then(() => {
  onClose();
  setTimeout(() => {
    sbBomb({
      srcUid,
      target: {
        uid: blockUid,
        start: triggerStart,                     // index into textarea.value
        end: currentTextarea.selectionStart,     // index into textarea.value
        windowId,
      },
      mutableCursor: !srcName.includes("<%NOCURSOR%>"),
    });
  }, 10);
});

src/utils/core.ts, sbBomb:

const originalText = getTextByBlockUid(uid);      // from the database
const prefix = originalText.substring(0, start);  // textarea offsets
const suffix = originalText.substring(end);
await updateBlock({ uid, text: `${prefix}${suffix}` });

start and end are offsets into textarea.value, but they are applied to a string read from the database. If the two ever disagree, the splice cuts at the wrong position and whatever lies between the two offsets is dropped.

waitForBlock(blockUid, textarea.value) is presumably there to keep them in sync, but it reads textarea.value from the element captured in the component's props. That element may already be detached: the same function re-fetches the live node via document.getElementById(textarea.id) in order to read selectionStart, and there is an explicit comment elsewhere in the file noting that Roam re-renders a new textarea on every keypress:

const listeningEl = !!textarea.closest(".rm-reference-item")
  ? textarea.parentElement // Roam rerenders a new textarea in linked references on every keypress
  : textarea;

So the guard can be waiting on a stale value while the offsets come from the live one.

Cause 2 — triggerStart is the first occurrence of the trigger, not the nearest one

src/index.ts:

const refreshTrigger = (value: string) => {
  trigger = (getLegacy42Setting("SmartBlockTrigger") || value || "jj")
    .replace(/"/g, "")
    .replace(/\\/g, "\\\\")
    .replace(/\+/g, "\\+")
    .trim();
  triggerRegex = new RegExp(`${trigger}(.*)$`);
};
const valueToCursor = textarea.value.substring(0, textarea.selectionStart);
const match = triggerRegex.exec(valueToCursor);
if (match) {
  render({ ..., triggerStart: match.index, ... });
}

RegExp.prototype.exec on a non-global regex returns the leftmost match. If the block already contains the trigger string anywhere before the cursor, match.index points at that earlier occurrence, and everything from there up to the cursor is deleted.

Minimal repro with the default jj trigger — note that the user never intends to run a workflow at all:

  1. In an empty block, start typing foojjbar.
  2. The moment foojj is typed the trigger menu opens, with triggerStart = 3.
  3. It stays open while bar is typed: the keydown handler re-runs triggerRegex.exec(valueToCursor), gets the capture group bar, and treats it as a filter rather than closing.
  4. Press Enter, intending to start a new block. The menu's keydown handler intercepts it and calls onSelect.
  5. start = 3, end = 8, so the block is spliced down to foojjbar is gone.

So any block whose text happens to contain the trigger string can lose content on a plain Enter. jj is an unfortunate default in this respect — it turns up in ordinary content far more often than one might expect. A quick query over my own graph found ~50 blocks containing jj, almost all of it incidental:

  • URLs — share links, storage paths and document permalinks all carry random ID segments, and plenty of ordinary domain names contain the pair too
  • LaTeX subscripts — $$\hat{\mathcal{V_{jj}}}$$ (the jj element of a covariance matrix is completely standard notation)
  • Code snippets — for jj = 1:num_alpha

Any of those blocks is one stray Enter away from losing its tail.

Two smaller problems in the same function:

  • The trigger string is interpolated into a RegExp with only \ and + escaped. Any other regex metacharacter in a user-configured trigger (., *, ?, (, [, |, $) will either throw or silently match the wrong thing.
  • Clearing the Trigger setting does not disable the trigger — the || "jj" fallback silently restores the default. That is surprising for anyone trying to turn the feature off.

Real-world evidence

A block in my graph was mangled by a trigger-menu invocation. Genericised to remove personal content, but structurally identical — it went from

watch part of [[Some Page]] then ((someBlockRef)) and take notes

to

watch part of [[Some Page]]\b and take notes

The span then ((someBlockRef)) was removed outright, and a stray \b was left in its place.

I initially suspected block references were the trigger, since the damaged blocks tended to contain them. That turned out to be a false lead — a controlled run on a block containing a ((ref)) completed correctly. I could not reproduce the corruption on demand, so I cannot tell you which of the two causes above produced this particular instance. Both are visible in the code regardless, and Cause 2 is deterministic and trivially reproducible (see its repro steps).

The command palette path avoids both causes

src/index.ts, addCommandPaletteCommands:

sbBomb({
  srcUid: wf.uid,
  target: {
    uid: targetUid,
    isParent: false,
    start: getTextByBlockUid(targetUid).length,
    windowId,
  },
  mutableCursor: true,
});

sbBomb destructures { start = 0, end = start, ... }, so here start and end are both the full length of the database string. That makes prefix the entire text and suffix empty, so the updateBlock is a no-op, and introContent is the complete block text.

This is a useful workaround for anyone hitting the trigger-menu bug, and it also suggests the shape of a fix: derive the splice from a single source of truth.

Suggested fixes

Cause 1 — make the offsets and the string come from the same place. Either compute the replacement from the textarea value:

const originalText = textarea.value; // same source as start/end

or keep reading the block from the database and locate the trigger substring in that string instead of trusting textarea offsets.

Cause 2 — take the occurrence nearest the cursor rather than the first, and escape the trigger properly:

const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
triggerRegex = new RegExp(`${escapeRegExp(trigger)}(.*)$`);

and derive the start from valueToCursor.lastIndexOf(trigger) (or make the match anchored to the end) so that earlier occurrences of the trigger in the block's own text are ignored.

It would also help to treat an empty Trigger setting as "disabled" rather than falling back to jj.

Environment

  • SmartBlocks from Roam Depot, RoamJs/smartblocks main as of 2026-08
  • Roam Research web client, Chromium-based browser
  • Default trigger jj

Line references above are from main; the code is quoted rather than cited by line number so it stays findable if the file shifts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions