Skip to content

fix: support duplicate SmartBlock buttons with same label (#136)#148

Open
salmonumbrella wants to merge 1 commit intoRoamJS:mainfrom
salmonumbrella:fix/duplicate-button-labels
Open

fix: support duplicate SmartBlock buttons with same label (#136)#148
salmonumbrella wants to merge 1 commit intoRoamJS:mainfrom
salmonumbrella:fix/duplicate-button-labels

Conversation

@salmonumbrella
Copy link
Contributor

@salmonumbrella salmonumbrella commented Dec 24, 2025

Summary

Fixes #136 - Enables multiple SmartBlock buttons with the same label in a single block to trigger their respective workflows correctly.

Problem

When a block contains multiple buttons with identical labels:

{{b:SmartBlock:UserDNPToday:RemoveButton=false,Icon=archive}}
{{b:SmartBlock:UserDNPDateSelect:RemoveButton=false}}

Both buttons would trigger the first workflow (UserDNPToday) because parseSmartBlockButton always returned the first regex match.

Solution

  1. Modified parseSmartBlockButton.ts: Added an occurrenceIndex parameter that uses matchAll() to find all matching buttons and returns the Nth occurrence
  2. Modified index.ts: Track button occurrence counts per block and pass the correct index when registering each button

How it works:

  • As buttons render in the DOM (in text order), each button with a given label gets an incrementing occurrence index
  • First "b" button → occurrenceIndex=0 → matches first {{b:SmartBlock:...}}
  • Second "b" button → occurrenceIndex=1 → matches second {{b:SmartBlock:...}}

Backward compatibility:

  • occurrenceIndex defaults to 0, so blocks with unique button labels work identically to before

Summary by CodeRabbit

  • New Features

    • SmartBlock buttons now distinguish multiple identical-label instances within the same block by tracking per-button occurrence so interactions target the correct instance.
  • Bug Fixes

    • Improved occurrence-aware registration, cleanup and disconnection handling to avoid stale handlers and ensure counts are decremented reliably during dynamic updates and re-renders.
  • Tests

    • Added tests covering multiple occurrences, out-of-bounds/negative indices, special-character labels, and correct per-occurrence parsing.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 24, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds per-block tracking for SmartBlock button occurrences (counts, texts, generation IDs, cleanup callbacks, and rendered elements). Computes an occurrenceIndex for each rendered button and threads it through registerElAsSmartBlockTrigger to parseSmartBlockButton. parseSmartBlockButton gains an optional occurrenceIndex, uses an escaped global regex with matchAll to select a specific occurrence, and returns null for out-of-range indices. Registers per-button cleanup callbacks, decrements occurrence counts on removal, pre-emptively cleans disconnected elements, and removes per-block state when counts reach zero. Tests added for multiple occurrences and edge cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: support duplicate SmartBlock buttons with same label (#136)' accurately describes the main change—enabling multiple buttons with identical labels to work correctly by tracking occurrence indices.
Linked Issues check ✅ Passed The PR fully addresses issue #136 by adding occurrence-index tracking and passing it through the registration and parsing pipeline, allowing each same-label button to trigger its corresponding workflow correctly.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the stated objective: occurrence tracking structures, button registration flow modifications, parsing logic updates, and comprehensive test coverage for multiple button scenarios remain in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/index.ts (1)

811-812: Consider potential memory growth with buttonOccurrences.

The buttonOccurrences map tracks counts per block UID. While cleanup occurs in the unload callback (lines 842-854), if unloads fail to trigger (e.g., observer disconnects before cleanup), entries may accumulate.

This is low-risk since block UIDs are unique per session and the map size is bounded by the number of blocks with SmartBlock buttons, but worth noting for long-running sessions.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1b7d43 and a2a9bf9.

📒 Files selected for processing (2)
  • src/index.ts
  • src/utils/parseSmartBlockButton.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/index.ts (1)
src/utils/parseSmartBlockButton.ts (1)
  • parseSmartBlockButton (1-56)
🔇 Additional comments (6)
src/utils/parseSmartBlockButton.ts (2)

1-4: LGTM on the signature change.

The addition of occurrenceIndex with a default value of 0 ensures backward compatibility while enabling selection of specific button occurrences.


23-28: LGTM on the multi-match logic.

The switch to matchAll() with bounds checking correctly handles:

  • Empty match arrays (returns null)
  • Out-of-bounds occurrence indices (returns null)
  • Valid indices (selects the correct match)

The redundant if (!match) return null; on line 28 is defensive but harmless since line 25 already ensures the index is valid.

src/index.ts (4)

595-612: LGTM on the parameter addition.

Adding occurrenceIndex to registerElAsSmartBlockTrigger with a default of 0 maintains backward compatibility for other callers like the TODO observer (line 872-877).


823-837: Verify button rendering order matches text order.

The occurrence tracking assumes buttons are observed in the same order they appear in the block text. If the DOM observer fires callbacks in a different order (e.g., due to async rendering), buttons could receive incorrect occurrenceIndex values.

Consider adding a comment documenting this assumption, or sorting buttons by their DOM position before assigning indices:

🔎 Alternative: Sort by DOM position

If ordering issues arise, an alternative approach would be to batch-process all buttons in a block and assign indices based on DOM order:

// Instead of incrementing per-callback, collect all buttons first
// and assign indices based on their position in the DOM

860-883: Note: TODO observer doesn't pass occurrenceIndex.

The TODO observer (lines 872-877) doesn't track occurrences like the button observer. If multiple TODO SmartBlocks with the same label exist in a block, they would all resolve to the first match.

This may be intentional if TODO SmartBlocks don't support duplicate labels, but worth confirming.


842-854: Occurrence index can silently fail to register buttons if decrement happens without re-observation.

When a button is removed, the blockOccurrences counter is decremented (line 852). If the remaining buttons aren't re-observed before the next button registration, the occurrenceIndex read from the map will be incorrect. This causes parseSmartBlockButton to return null (line 25 of parseSmartBlockButton.ts), and the button won't be registered as a trigger—silently.

The bounds check prevents crashes, but creates silent registration failures. This is mitigated in practice because block edits typically trigger a full DOM re-render of button elements, causing fresh observation and index reassignment.

@mdroidian mdroidian self-requested a review January 6, 2026 00:19
Copy link
Collaborator

@mdroidian mdroidian left a comment

Choose a reason for hiding this comment

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

Thanks for the submission @salmonumbrella !

Unfortunately, this one is a bit trickier. We'd have to make sure it passes all of the parseSmartBlockButton.ts tests, and devise new passing tests that cover the new scenarios (multiple buttons, with labels, without labels, with various attributes, etc).

You can run the tests locally with npm run test

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/utils/parseSmartBlockButton.ts (1)

23-28: Approve logic with minor observation on null check.

The matchAll approach correctly finds all button occurrences and selects the requested index. The bounds check on line 25 prevents out-of-bounds access for positive indices.

The null check on line 28 appears redundant after the bounds check on line 25, but it does provide additional safety for negative occurrenceIndex values (e.g., -1 would bypass line 25 but fail on line 28). If negative indices are not expected in practice, consider adding an explicit check on line 25: occurrenceIndex < 0 || occurrenceIndex >= matches.length.

🔎 Optional: Add explicit negative index check
  const matches = Array.from(text.matchAll(buttonRegex));
- if (matches.length === 0 || occurrenceIndex >= matches.length) return null;
+ if (matches.length === 0 || occurrenceIndex < 0 || occurrenceIndex >= matches.length) return null;
  
  const match = matches[occurrenceIndex];
- if (!match) return null;

This makes the validation more explicit and eliminates the need for the subsequent null check.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a2a9bf9 and 2c9f199.

📒 Files selected for processing (2)
  • src/utils/parseSmartBlockButton.ts
  • tests/buttonParsing.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
tests/buttonParsing.test.ts (1)
src/utils/parseSmartBlockButton.ts (1)
  • parseSmartBlockButton (1-56)
🔇 Additional comments (6)
tests/buttonParsing.test.ts (4)

69-85: LGTM!

This test effectively validates the core fix for issue #136. It confirms that multiple buttons with identical labels ("Run It") correctly map to their respective workflows (WorkflowOne and WorkflowTwo) when distinguished by occurrenceIndex.


87-102: LGTM!

Good coverage for unlabeled buttons. This test ensures the occurrence tracking mechanism works consistently for both labeled and unlabeled SmartBlock buttons.


104-108: LGTM!

Essential boundary check. This test ensures the parser fails gracefully when requesting a non-existent occurrence rather than throwing an error or returning incorrect data.


110-117: LGTM!

This test validates proper handling of plus signs (regex metacharacters) in button labels. It confirms that the regex escaping fix (changing /\+/g in the parser) works correctly. Based on past review comments.

src/utils/parseSmartBlockButton.ts (2)

1-4: LGTM!

The addition of the optional occurrenceIndex parameter with a default value of 0 maintains backward compatibility while enabling the new multi-occurrence functionality. Existing callers continue to work without modification.


16-21: Address ReDoS vulnerability from incomplete regex escaping of user input.

The label parameter originates from user-editable document content (DOM button text in Roam Research), making it a genuine ReDoS risk when used in regex construction. While only the + character is escaped via /\+/g, other regex metacharacters (*, ., ?, [, ], ^, $, {, }, |, (, ), \) remain unescaped. Malicious input like a+a+a+a+a+aaa... can construct patterns that cause exponential backtracking.

Recommendations:

  • Escape all regex metacharacters in label before constructing the regex, or
  • Use a regex safety library like recheck to detect unsafe patterns, or
  • Validate/sanitize labels to allow only safe characters

The non-greedy quantifier (.*?) provides partial mitigation but does not eliminate the vulnerability.

@salmonumbrella
Copy link
Contributor Author

Thanks @mdroidian! I added broader regex escaping for SmartBlock labels and a test covering labels with regex special characters, plus ran the full test suite (npm run test). Let me know if you'd like any other cases covered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/utils/parseSmartBlockButton.ts (1)

25-30: Consider explicit handling of negative indices for clarity.

The logic correctly finds all matches and validates bounds. Line 30's null check is technically redundant after the bounds check on line 27, but serves as defensive programming.

One optional improvement: explicitly validate that occurrenceIndex >= 0 on line 27 for clarity, rather than relying on array[-1] returning undefined.

🔎 Optional refactor for explicit validation
  // Find all matches
  const matches = Array.from(text.matchAll(buttonRegex));
- if (matches.length === 0 || occurrenceIndex >= matches.length) return null;
+ if (matches.length === 0 || occurrenceIndex < 0 || occurrenceIndex >= matches.length) return null;

  const match = matches[occurrenceIndex];
- if (!match) return null;
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2c9f199 and a02a659.

📒 Files selected for processing (2)
  • src/utils/parseSmartBlockButton.ts
  • tests/buttonParsing.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/buttonParsing.test.ts
🧰 Additional context used
🪛 ast-grep (0.40.3)
src/utils/parseSmartBlockButton.ts

[warning] 18-21: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(
{{(${escapeRegex(trimmedLabel)}):(?:42)?SmartBlock:(.*?)}},
"g"
)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (4)
src/utils/parseSmartBlockButton.ts (4)

1-4: LGTM! Well-designed parameter addition.

The occurrenceIndex parameter with a default value of 0 maintains backward compatibility while enabling the new multiple-occurrence behavior. The parameter is well-named and positioned appropriately.


15-16: LGTM! Proper regex escaping implementation.

The escapeRegex helper correctly escapes all regex metacharacters using a comprehensive character class. This properly addresses the previous issue with escaping special characters like + in button labels.


18-23: LGTM! Regex construction is correct and safe.

The use of escapeRegex properly sanitizes the label before regex construction, and the global flag "g" is correctly applied for matchAll to function. The static analysis warning about ReDoS is noted, but the risk is minimal here because:

  • The label is fully escaped, treating it as a literal string
  • The surrounding pattern {{(...):(42)?SmartBlock:(.*?)}} uses non-greedy quantifiers and is relatively simple
  • User input (label) cannot inject malicious regex patterns due to escaping

31-57: LGTM! Parsing logic is sound.

The parsing logic correctly extracts all button properties from the selected match occurrence. The handling of nested structures with the ESCAPE_COMMA mechanism is appropriate, and the final variables object construction including the ButtonContent field is correct.

@mdroidian mdroidian self-requested a review January 15, 2026 04:06
Copy link
Collaborator

@mdroidian mdroidian left a comment

Choose a reason for hiding this comment

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

This seems like we’re getting closer.

I tested two SmartBlocks using the same label. They render and work correctly on the initial pass, but if a re-render is triggered (for example, when the block is edited), they are no longer recognized as components.

This definitely looks like something that needs further investigation.

Loom walkthrough:
https://www.loom.com/share/5bd86471ecff4d4b96ffc83dd3926679

@salmonumbrella
Copy link
Contributor Author

@mdroidian Thanks for the repro and Loom! I pushed a fix that resets per-block button tracking on re-render so buttons stay recognized after edits. Would you mind retesting when you get a chance?

@mdroidian
Copy link
Collaborator

@salmonumbrella — do you happen to have access to Loom or another screen-recording tool? If so, would you be able to run through a few manual test flows and record them so we can have an initial stress-test for this?

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/index.ts`:
- Around line 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.
🧹 Nitpick comments (2)
src/index.ts (2)

811-816: Consider consolidating per-block tracking into a single structure.

Five parallel Maps keyed by parentUid (or element) must stay in sync manually, which is error-prone during cleanup. A single Map<string, BlockButtonState> holding all per-block fields would reduce the surface for desynchronization bugs.

// Example consolidated structure
interface BlockButtonState {
  text: string;
  generation: number;
  occurrences: Map<string, number>;
  elements: Set<HTMLElement>;
}
const blockButtonState = new Map<string, BlockButtonState>();

864-900: Cleanup has unreachable/redundant occurrence-decrement code after bulk deletion.

When blockButtons.size === 0 (line 873), lines 870–878 delete all per-block tracking — including buttonOccurrences, buttonTextByBlockUid, and buttonGenerationByBlockUid. After that:

  • Line 880 reads buttonGenerationByBlockUid.get(parentUid)undefined (just deleted).
  • Lines 884–898 read buttonOccurrences.get(parentUid)undefined (just deleted).

So the code after line 878 is effectively dead in the blockButtons.size === 0 path. It works today because of the null-guards, but it's fragile and confusing. Consider returning early after the bulk cleanup:

Suggested simplification
           if (blockButtons) {
             blockButtons.delete(b);
             if (blockButtons.size === 0) {
               buttonElementsByBlockUid.delete(parentUid);
               buttonOccurrences.delete(parentUid);
               buttonTextByBlockUid.delete(parentUid);
               buttonGenerationByBlockUid.delete(parentUid);
+              return;
             }
           }

Comment on lines +850 to +862
// 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,
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.

@salmonumbrella salmonumbrella force-pushed the fix/duplicate-button-labels branch 2 times, most recently from eb46c78 to 20eba48 Compare February 14, 2026 23:11
@salmonumbrella
Copy link
Contributor Author

@mdroidian Thanks again for the Loom repro. I pushed a follow-up fix to harden duplicate-label button registration across block re-renders/edits. Could you retest the same scenario (two SmartBlocks with the same label, then edit the block) and confirm they still stay recognized as components?

@mdroidian
Copy link
Collaborator

@mdroidian Thanks again for the Loom repro. I pushed a follow-up fix to harden duplicate-label button registration across block re-renders/edits. Could you retest the same scenario (two SmartBlocks with the same label, then edit the block) and confirm they still stay recognized as components?

do you happen to have access to Loom or another screen-recording tool? If so, would you be able to run through a few manual test flows and record them so we can have an initial stress-test for this?

@mdroidian
Copy link
Collaborator

@salmonumbrella

Hi — thank you for putting this together. I appreciate the effort here.

We just added a formal contributing guide, so this likely wasn’t visible when you started the PR. Going forward, we’re trying to standardize reviews around it to keep things manageable:

https://github.com/RoamJS/contributing/blob/main/contributing.md

A couple adjustments are needed before this can move forward:

  • Addressing the coderabbit review bot comments
  • Include a short (2–3 min) Loom (or other) walkthrough showing what changed and how you verified it.

Once those are complete, please tag me in a comment or as a reviewer and I’ll be happy to take a look.

@salmonumbrella salmonumbrella force-pushed the fix/duplicate-button-labels branch from 20eba48 to 84de9f8 Compare February 18, 2026 10:46
@salmonumbrella salmonumbrella changed the title fix: support multiple SmartBlock buttons with same label in one block fix: support duplicate SmartBlock buttons with same label (#136) Feb 18, 2026
@salmonumbrella
Copy link
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/index.ts`:
- Around line 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.

---

Duplicate comments:
In `@src/index.ts`:
- Around line 855-867: The occurrenceIndex calculation relying on callback order
is fragile; instead compute the index from the live DOM when registering the
element: inside the registration path that uses label, parentUid,
occurrenceIndex and calls registerElAsSmartBlockTrigger (and where
buttonGenerationByBlockUid/buttonOccurrences are used), query the block element
for all sibling buttons (or elements with the same selector/textContent) whose
trimmed textContent equals label, build the NodeList/array in DOM order and set
occurrenceIndex to the indexOf the specific element b; then use that DOM-derived
occurrenceIndex when calling registerElAsSmartBlockTrigger and update
buttonOccurrences/buttonGenerationByBlockUid as needed (or remove the counter
increment logic) so registration no longer depends on MutationObserver callback
order.

Comment on lines +869 to +904
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);
}
}
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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support two SmartBlocks Buttons in the same block that have the same label

2 participants