Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,16 +603,18 @@ export default runExtension(async ({ extensionAPI }) => {
el,
parentUid,
hideIcon,
occurrenceIndex = 0,
}: {
textContent: string;
text: string;
el: HTMLElement;
parentUid: string;
hideIcon?: boolean;
occurrenceIndex?: number;
}) => {
// We include textcontent here bc there could be multiple smartblocks in a block
const label = textContent.trim();
const parsed = parseSmartBlockButton(label, text);
const parsed = parseSmartBlockButton(label, text, occurrenceIndex);
if (parsed) {
const { index, full, buttonContent, workflowName, variables } = parsed;
const clickListener = () => {
Expand Down Expand Up @@ -811,29 +813,107 @@ export default runExtension(async ({ extensionAPI }) => {
};

const unloads = new Set<() => void>();
// Track button occurrences per block: blockUid -> label -> count
const buttonOccurrences = new Map<string, Map<string, number>>();
const buttonTextByBlockUid = new Map<string, string>();
const buttonGenerationByBlockUid = new Map<string, number>();
const buttonCleanupByElement = new Map<HTMLElement, () => void>();
const buttonElementsByBlockUid = new Map<string, Set<HTMLElement>>();

const buttonLogoObserver = createHTMLObserver({
className: "bp3-button bp3-small dont-focus-block",
tag: "BUTTON",
callback: (b) => {
const parentUid = getBlockUidFromTarget(b);
if (parentUid && !b.hasAttribute("data-roamjs-smartblock-button")) {
// Clean up disconnected elements first so stale counts don't break
// occurrence tracking during re-renders.
const trackedButtons = buttonElementsByBlockUid.get(parentUid);
if (trackedButtons) {
Array.from(trackedButtons).forEach((el) => {
if (!el.isConnected) {
buttonCleanupByElement.get(el)?.();
}
});
}

const text = getTextByBlockUid(parentUid);
b.setAttribute("data-roamjs-smartblock-button", "true");

// We include textcontent here bc there could be multiple smartblocks in a block
// TODO: if multiple smartblocks have the same textContent, we need to distinguish them
const cachedText = buttonTextByBlockUid.get(parentUid);
if (cachedText !== text) {
buttonTextByBlockUid.set(parentUid, text);
buttonGenerationByBlockUid.set(
parentUid,
(buttonGenerationByBlockUid.get(parentUid) || 0) + 1
);
buttonOccurrences.set(parentUid, new Map());
} else if (!buttonOccurrences.has(parentUid)) {
buttonOccurrences.set(parentUid, new Map());
}

// Track occurrence index for buttons with the same label in the same block
const label = (b.textContent || "").trim();
const blockOccurrences = buttonOccurrences.get(parentUid)!;
const occurrenceIndex = blockOccurrences.get(label) || 0;
blockOccurrences.set(label, occurrenceIndex + 1);
const generation = buttonGenerationByBlockUid.get(parentUid) || 0;

const unload = registerElAsSmartBlockTrigger({
textContent: b.textContent || "",
text,
el: b,
parentUid,
occurrenceIndex,
Comment on lines +855 to +867
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for createHTMLObserver definition
rg -n "createHTMLObserver" --type=ts -B 2 -A 10 | head -100

Repository: RoamJS/smartblocks

Length of output: 3497


🏁 Script executed:

#!/bin/bash
# Look for the implementation of createHTMLObserver
rg -n "function createHTMLObserver|const createHTMLObserver" --type=ts -A 20

Repository: RoamJS/smartblocks

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Also search for createBlockObserver and MutationObserver usage
rg -n "MutationObserver" --type=ts -B 3 -A 10

Repository: RoamJS/smartblocks

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Check the buttonOccurrences and buttonGenerationByBlockUid setup and usage
rg -n "buttonOccurrences|buttonGenerationByBlockUid" --type=ts -B 2 -A 3

Repository: RoamJS/smartblocks

Length of output: 3375


🏁 Script executed:

#!/bin/bash
# Search for registerElAsSmartBlockTrigger to see how occurrenceIndex is used
rg -n "registerElAsSmartBlockTrigger" --type=ts -B 2 -A 15 | head -150

Repository: RoamJS/smartblocks

Length of output: 2574


🏁 Script executed:

#!/bin/bash
# Look for any DOM position or ordering checks in the codebase
rg -n "compareDocumentPosition|getBoundingClientRect|offsetHeight|offsetWidth|parentNode.*indexOf|Array\.from.*siblings" --type=ts | head -50

Repository: RoamJS/smartblocks

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Check the full context around lines 850-862 to see the cleanup logic
sed -n '810,910p' src/index.ts

Repository: RoamJS/smartblocks

Length of output: 4283


🏁 Script executed:

#!/bin/bash
# Look at the full registerElAsSmartBlockTrigger implementation to see how occurrenceIndex is used
sed -n '595,700p' src/index.ts

Repository: RoamJS/smartblocks

Length of output: 3676


🏁 Script executed:

#!/bin/bash
# Search for any usage of occurrenceIndex after it's passed to registerElAsSmartBlockTrigger
rg -n "occurrenceIndex" --type=ts -B 2 -A 2

Repository: RoamJS/smartblocks

Length of output: 2293


🏁 Script executed:

#!/bin/bash
# Look at the full parseSmartBlockButton implementation
cat src/utils/parseSmartBlockButton.ts

Repository: RoamJS/smartblocks

Length of output: 1794


🌐 Web query:

MutationObserver callback DOM order guarantee

💡 Result:

MutationObserver does not promise “DOM/tree order” sorting of records. What it does guarantee (per spec) is queue/insertion order:

  • Each observer has a record queue (a spec “queue”). When a mutation is detected, a MutationRecord is enqueued onto that queue. [1]
  • In the Infra standard, to enqueue means append to the end of the queue. So the callback receives records in the same order they were enqueued (i.e., the order the DOM algorithms queued them during execution). [2][1]
  • Delivery happens later in a microtask (“notify mutation observers” clones the queue and passes that list to your callback). [1]

So: you can rely on chronological enqueue order, but not on “document order”, and you should expect multiple changes from the same task to arrive together in one callback. [1]

Sources
[1] WHATWG DOM Standard — mutation observers, record queue, queue/enqueue, notify steps https://dom.spec.whatwg.org/
[2] WHATWG Infra Standard — “To enqueue in a queue is to append to it.” https://infra.spec.whatwg.org/


🌐 Web query:

MutationObserver DOM traversal order specification W3C

💡 Result:

In the W3C DOM spec line (DOM 4.1 drafts/notes), MutationObserver delivery/ordering is defined algorithmically, not as a “DOM traversal order” like preorder/postorder.

What the spec actually says

  • When a mutation happens, the UA queues a mutation record by first computing nodes as the inclusive ancestors of target, then iterating: for each node in nodes, then for each registered observer in that node’s registered-observer list. This is how it determines which observers are “interested”. [1]
  • The spec defines “tree order” separately as preorder, depth-first traversal, but the mutation-record queuing algorithm above does not say “iterate ancestors in tree order” (or reverse tree order). [2]
  • Delivery happens later in a (compound) microtask: for each MutationObserver in the notify list, it takes a copy of that observer’s record queue (a FIFO concept) and invokes the callback with that list. [3]

Practical implication

  • The spec guarantees: records are delivered to each observer in the order they were enqueued into that observer’s record queue (since delivery uses a copy of that queue). [3]
  • The spec does not explicitly guarantee a particular ancestor traversal direction (root→target vs target→root) for “inclusive ancestors” in the queuing step, so there isn’t a separately named “DOM traversal order” requirement for MutationObserver beyond the algorithms. [1], [2]

Sources (W3C/WHATWG specs):
[1] DOM Standard / DOM 4.1: “Queuing a mutation record” (inclusive ancestors; nested loops) https://dom.spec.whatwg.org/#queue-a-mutation-record and W3C DOM 4.1 WD text https://www.w3.org/TR/2017/WD-dom41-20170321/
[2] W3C DOM 4.1 WD: definition of “tree order” (preorder, depth-first) https://www.w3.org/TR/2017/WD-dom41-20170321/
[3] W3C DOM 4.1 NOTE: “notify mutation observers” (copy record queue, then callback) https://www.w3.org/TR/2020/NOTE-dom41-20200317/


Occurrence index could map to wrong button if mutations fire out of DOM order.

The occurrence counter assumes createHTMLObserver processes buttons within the same block in the order they appear in the DOM. However, MutationObserver (which underlies createHTMLObserver) only guarantees chronological record enqueue order, not document order. If mutations are detected in a different sequence than the buttons' DOM positions, they receive misaligned occurrence indices, causing parseSmartBlockButton to select the wrong regex match in the block text and trigger the wrong workflow.

The code mitigates this somewhat with cleanup of disconnected elements and generation tracking, but doesn't address the core reordering risk. Consider validating the computed occurrenceIndex against the button's actual DOM position among its siblings, or computing indices from the live DOM before selecting a match.

🤖 Prompt for AI Agents
In `@src/index.ts` around lines 850 - 862, The occurrenceIndex can be wrong when
mutations arrive out-of-order; instead of relying solely on the incremented
counter in blockOccurrences, compute the index from the live DOM to ensure
document order: for the current element b (passed to
registerElAsSmartBlockTrigger), query its parent (parentUid -> DOM node) for all
sibling button elements with the same label/textContent and count how many
precede b to derive occurrenceIndex, then update blockOccurrences and use that
DOM-derived occurrenceIndex (and keep using buttonGenerationByBlockUid as
before) before calling registerElAsSmartBlockTrigger/parseSmartBlockButton so
the selected regex match aligns with actual DOM position.

});
unloads.add(() => {
const cleanup = () => {
if (!buttonCleanupByElement.has(b)) return;
buttonCleanupByElement.delete(b);
unloads.delete(cleanup);
b.removeAttribute("data-roamjs-smartblock-button");
unload();
});
const blockButtons = buttonElementsByBlockUid.get(parentUid);
if (blockButtons) {
blockButtons.delete(b);
if (blockButtons.size === 0) {
buttonElementsByBlockUid.delete(parentUid);
buttonOccurrences.delete(parentUid);
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
}
}
if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) {
return;
}
// Clean up occurrence tracking when button is removed
const blockOccurrences = buttonOccurrences.get(parentUid);
if (blockOccurrences) {
const currentCount = blockOccurrences.get(label) || 0;
if (currentCount <= 1) {
blockOccurrences.delete(label);
if (blockOccurrences.size === 0) {
buttonOccurrences.delete(parentUid);
if (!buttonElementsByBlockUid.get(parentUid)?.size) {
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
}
}
} else {
blockOccurrences.set(label, currentCount - 1);
}
}
Comment on lines +869 to +904
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Cleanup logic has a dead-code path after full block state deletion.

When blockButtons.size === 0 (Lines 878–883), all tracking maps for parentUid are deleted — including buttonGenerationByBlockUid and buttonOccurrences. The subsequent code at Lines 885–903 then reads from these now-deleted maps:

  • Line 885: buttonGenerationByBlockUid.get(parentUid)undefined, so (undefined || 0) !== generation is true whenever generation > 0, causing an early return.
  • If generation === 0: buttonOccurrences.get(parentUid)undefined, so the if at Line 890 is skipped.

This means Lines 885–904 are effectively unreachable with any meaningful side-effect when blockButtons.size === 0. While not a bug (behavior is correct), the control flow is confusing. Consider reordering: perform the generation check and occurrence decrement before the full-state deletion, or add an early return after Line 883 with a clarifying comment.

♻️ Suggested reorder for clarity
         const blockButtons = buttonElementsByBlockUid.get(parentUid);
         if (blockButtons) {
           blockButtons.delete(b);
-          if (blockButtons.size === 0) {
+          if (blockButtons.size === 0) {
             buttonElementsByBlockUid.delete(parentUid);
             buttonOccurrences.delete(parentUid);
             buttonTextByBlockUid.delete(parentUid);
             buttonGenerationByBlockUid.delete(parentUid);
+            // All buttons removed from block — full cleanup done.
+            return;
           }
         }
         if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) {
           return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const cleanup = () => {
if (!buttonCleanupByElement.has(b)) return;
buttonCleanupByElement.delete(b);
unloads.delete(cleanup);
b.removeAttribute("data-roamjs-smartblock-button");
unload();
});
const blockButtons = buttonElementsByBlockUid.get(parentUid);
if (blockButtons) {
blockButtons.delete(b);
if (blockButtons.size === 0) {
buttonElementsByBlockUid.delete(parentUid);
buttonOccurrences.delete(parentUid);
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
}
}
if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) {
return;
}
// Clean up occurrence tracking when button is removed
const blockOccurrences = buttonOccurrences.get(parentUid);
if (blockOccurrences) {
const currentCount = blockOccurrences.get(label) || 0;
if (currentCount <= 1) {
blockOccurrences.delete(label);
if (blockOccurrences.size === 0) {
buttonOccurrences.delete(parentUid);
if (!buttonElementsByBlockUid.get(parentUid)?.size) {
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
}
}
} else {
blockOccurrences.set(label, currentCount - 1);
}
}
const cleanup = () => {
if (!buttonCleanupByElement.has(b)) return;
buttonCleanupByElement.delete(b);
unloads.delete(cleanup);
b.removeAttribute("data-roamjs-smartblock-button");
unload();
const blockButtons = buttonElementsByBlockUid.get(parentUid);
if (blockButtons) {
blockButtons.delete(b);
if (blockButtons.size === 0) {
buttonElementsByBlockUid.delete(parentUid);
buttonOccurrences.delete(parentUid);
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
// All buttons removed from block — full cleanup done.
return;
}
}
if ((buttonGenerationByBlockUid.get(parentUid) || 0) !== generation) {
return;
}
// Clean up occurrence tracking when button is removed
const blockOccurrences = buttonOccurrences.get(parentUid);
if (blockOccurrences) {
const currentCount = blockOccurrences.get(label) || 0;
if (currentCount <= 1) {
blockOccurrences.delete(label);
if (blockOccurrences.size === 0) {
buttonOccurrences.delete(parentUid);
if (!buttonElementsByBlockUid.get(parentUid)?.size) {
buttonTextByBlockUid.delete(parentUid);
buttonGenerationByBlockUid.delete(parentUid);
}
}
} else {
blockOccurrences.set(label, currentCount - 1);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 869 - 904, The cleanup function's control flow is
confusing because after deleting all per-block state (buttonElementsByBlockUid,
buttonOccurrences, buttonTextByBlockUid, buttonGenerationByBlockUid) the later
checks that reference buttonGenerationByBlockUid and buttonOccurrences are
effectively dead; to fix, inside cleanup (the closure referenced by
cleanup/unload and using parentUid, label, generation) either move the
generation-check and occurrence-decrement logic to run before the block-wide
deletions, or simply add an explicit return immediately after the
blockButtons.size === 0 branch (after deleting the per-block maps) with a short
comment explaining we already removed all tracking for parentUid so no further
occurrence/generation work is needed; update references to
buttonGenerationByBlockUid and buttonOccurrences accordingly to avoid reading
deleted entries.

};
const blockButtons =
buttonElementsByBlockUid.get(parentUid) || new Set<HTMLElement>();
blockButtons.add(b);
buttonElementsByBlockUid.set(parentUid, blockButtons);
buttonCleanupByElement.set(b, cleanup);
unloads.add(cleanup);
}
},
removeCallback: (b) => {
buttonCleanupByElement.get(b)?.();
},
});

const todoObserver = createHTMLObserver({
Expand Down
25 changes: 20 additions & 5 deletions src/utils/parseSmartBlockButton.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const escapeRegex = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

export const parseSmartBlockButton = (
label: string,
text: string
text: string,
occurrenceIndex: number = 0
):
| {
index: number;
Expand All @@ -14,11 +18,22 @@ export const parseSmartBlockButton = (
const trimmedLabel = label.trim();
const buttonRegex = trimmedLabel
? new RegExp(
`{{(${trimmedLabel.replace(/\\+/g, "\\+")}):(?:42)?SmartBlock:(.*?)}}`
`{{(${escapeRegex(trimmedLabel)}):(?:42)?SmartBlock:(.*?)}}`,
"g"
)
: /{{\s*:(?:42)?SmartBlock:(.*?)}}/;
const match = buttonRegex.exec(text);
if (!match) return null;
: /{{\s*:(?:42)?SmartBlock:(.*?)}}/g;

// Find all matches
const matches = Array.from(text.matchAll(buttonRegex));
if (
matches.length === 0 ||
occurrenceIndex < 0 ||
occurrenceIndex >= matches.length
) {
return null;
}

const match = matches[occurrenceIndex];
const index = match.index;
const full = match[0];
const buttonContent = trimmedLabel ? match[1] || "" : "";
Expand Down
65 changes: 65 additions & 0 deletions tests/buttonParsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,68 @@ test("parses SmartBlock button for today's entry", () => {
ButtonContent: "Create Today's Entry",
});
});

test("parses multiple labeled SmartBlock buttons in the same block", () => {
const text =
"{{Run It:SmartBlock:WorkflowOne:RemoveButton=false}} and {{Run It:SmartBlock:WorkflowTwo:Icon=locate,Order=last}}";
const first = parseSmartBlockButton("Run It", text, 0);
const second = parseSmartBlockButton("Run It", text, 1);
expect(first?.workflowName).toBe("WorkflowOne");
expect(first?.variables).toMatchObject({
RemoveButton: "false",
ButtonContent: "Run It",
});
expect(second?.workflowName).toBe("WorkflowTwo");
expect(second?.variables).toMatchObject({
Icon: "locate",
Order: "last",
ButtonContent: "Run It",
});
});

test("parses multiple unlabeled SmartBlock buttons in the same block", () => {
const text =
"{{:SmartBlock:first:Icon=locate}} and {{:SmartBlock:second:RemoveButton=false}}";
const first = parseSmartBlockButton("", text, 0);
const second = parseSmartBlockButton("", text, 1);
expect(first?.buttonText).toBe("first:Icon=locate");
expect(first?.variables).toMatchObject({
Icon: "locate",
ButtonContent: "",
});
expect(second?.buttonText).toBe("second:RemoveButton=false");
expect(second?.variables).toMatchObject({
RemoveButton: "false",
ButtonContent: "",
});
});

test("returns null when occurrence index is out of bounds", () => {
const text = "{{Only One:SmartBlock:Workflow}}";
const result = parseSmartBlockButton("Only One", text, 2);
expect(result).toBeNull();
});

test("returns null when occurrence index is negative", () => {
const text = "{{Only One:SmartBlock:Workflow}}";
const result = parseSmartBlockButton("Only One", text, -1);
expect(result).toBeNull();
});

test("parses SmartBlock button label containing plus signs", () => {
const text = "{{Add+More:SmartBlock:PlusWorkflow}}";
const result = parseSmartBlockButton("Add+More", text);
expect(result?.workflowName).toBe("PlusWorkflow");
expect(result?.variables).toMatchObject({
ButtonContent: "Add+More",
});
});

test("parses SmartBlock button label with regex special characters", () => {
const text = "{{Add+(Test)[One]?:SmartBlock:WeirdWorkflow}}";
const result = parseSmartBlockButton("Add+(Test)[One]?", text);
expect(result?.workflowName).toBe("WeirdWorkflow");
expect(result?.variables).toMatchObject({
ButtonContent: "Add+(Test)[One]?",
});
});
Loading