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:
- The block loses text permanently (undo is the only recovery, if the user notices in time).
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:
- In an empty block, start typing
foojjbar.
- The moment
foojj is typed the trigger menu opens, with triggerStart = 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.
- Press Enter, intending to start a new block. The menu's keydown handler intercepts it and calls
onSelect.
start = 3, end = 8, so the block is spliced down to foo — jjbar 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.
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
sbBombwith 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
sbBombstrips the trigger out of the block by splicingprefix + suffixback 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:
prefixis also stored asintroContent, which seedssmartBlocksContext.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 —
prefixandsuffixare 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:src/utils/core.ts,sbBomb:startandendare offsets intotextarea.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 readstextarea.valuefrom the element captured in the component's props. That element may already be detached: the same function re-fetches the live node viadocument.getElementById(textarea.id)in order to readselectionStart, and there is an explicit comment elsewhere in the file noting that Roam re-renders a new textarea on every keypress:So the guard can be waiting on a stale value while the offsets come from the live one.
Cause 2 —
triggerStartis the first occurrence of the trigger, not the nearest onesrc/index.ts:RegExp.prototype.execon a non-global regex returns the leftmost match. If the block already contains the trigger string anywhere before the cursor,match.indexpoints at that earlier occurrence, and everything from there up to the cursor is deleted.Minimal repro with the default
jjtrigger — note that the user never intends to run a workflow at all:foojjbar.foojjis typed the trigger menu opens, withtriggerStart = 3.baris typed: the keydown handler re-runstriggerRegex.exec(valueToCursor), gets the capture groupbar, and treats it as a filter rather than closing.onSelect.start = 3,end = 8, so the block is spliced down tofoo—jjbaris gone.So any block whose text happens to contain the trigger string can lose content on a plain Enter.
jjis 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 containingjj, almost all of it incidental:$$\hat{\mathcal{V_{jj}}}$$(thejjelement of a covariance matrix is completely standard notation)for jj = 1:num_alphaAny of those blocks is one stray Enter away from losing its tail.
Two smaller problems in the same function:
RegExpwith only\and+escaped. Any other regex metacharacter in a user-configured trigger (.,*,?,(,[,|,$) will either throw or silently match the wrong thing.|| "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
to
The span
then ((someBlockRef))was removed outright, and a stray\bwas 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:sbBombdestructures{ start = 0, end = start, ... }, so herestartandendare both the full length of the database string. That makesprefixthe entire text andsuffixempty, so theupdateBlockis a no-op, andintroContentis 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:
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:
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
RoamJs/smartblocksmainas of 2026-08jjLine references above are from
main; the code is quoted rather than cited by line number so it stays findable if the file shifts.