Skip to content

fix(js-core): use closest() fallback for nested click target matching#7327

Merged
Dhruwang merged 4 commits intoformbricks:mainfrom
bharathkumar39293:fix/nested-click-target-delegate
Feb 27, 2026
Merged

fix(js-core): use closest() fallback for nested click target matching#7327
Dhruwang merged 4 commits intoformbricks:mainfrom
bharathkumar39293:fix/nested-click-target-delegate

Conversation

@bharathkumar39293
Copy link
Contributor

@bharathkumar39293 bharathkumar39293 commented Feb 22, 2026

What was broken
Formbricks CSS selector click actions silently fail when a user clicks inside a matched element rather than on it directly.

Fixes #7314

Example — you configure a click action targeting .submit-btn:

... Submit

When the user clicks the icon, event.target is — not .submit-btn. The SDK called:

ts
targetElement.matches(".submit-btn") // → false ❌ action dropped
The survey never triggers, with no error or warning.

This breaks any button with nested content — icon buttons, buttons with text wrappers, and virtually every component from Radix UI, MUI, shadcn/ui, and similar design systems.

What this PR does
Adds a .closest() fallback when .matches() fails, so the SDK walks up the DOM to find the nearest matching ancestor:// Before
if (!targetElement.matches(selector)) return false;

// After
const matchesDirectly = individualSelectors.every((sel) => targetElement.matches(sel));
if (!matchesDirectly) {
const ancestor = targetElement.closest(cssSelector);
if (!ancestor) return false;
matchedElement = ancestor as HTMLElement; // ✅ use the button, not the
}
The matched element is then used for all downstream checks (including innerHTML comparison), so attribute reads always happen on the correct node.

Performance: .closest() is only called as a fallback — direct target matches take the same fast path as before.

Tests
3 regression tests added that fail on the old code and pass on the new code:

✅ Clicking a child inside .my-btn → action fires correctly
✅ Clicking an element with no matching ancestor → correctly returns false
✅ Clicking the target directly → .closest() is not called (fast path preserved)
Full suite result: 232 tests · 19 files · all passing

When a user clicks a child element inside a button or div matched by
a CSS selector action (e.g. clicking the <svg> or <span> inside
<button class=my-btn>), event.target is the child, not the button.

Previously, evaluateNoCodeConfigClick() only called:
  targetElement.matches(selector)

This returned false for child elements even though an ancestor matched,
silently dropping the click action.

Fix: resolve matchedElement by trying direct .matches() first, then
falling back to .closest(cssSelector) to find the nearest ancestor.
Only if neither matches does the function return false.

Also moved innerHtml check to use matchedElement instead of the raw
click target, so element attributes are read from the correct node.

Regression tests added for:
- Child <span> click inside a matched button → now triggers correctly
- Child with no matching ancestor → still returns false
- Direct target click → closest() not called (fast path preserved)

Fixes: formbricks#7314
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 22, 2026

Walkthrough

The pull request updates the evaluateNoCodeConfigClick function in the common utilities to handle nested child click targets more robustly. When a CSS selector is provided, the function now tests the direct click target first and, if it doesn't match, uses the closest() method to find the nearest matching ancestor element. The innerHTML comparison is performed on this resolved element instead of the original target. Comprehensive regression tests are added to verify correct behavior for direct matches, ancestor fallbacks, and cases where no match is found. No public API signatures are modified.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: using closest() as a fallback for nested click target matching in DOM element resolution.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The pull request description thoroughly covers the issue, solution, and testing with clear code examples and performance considerations.

✏️ 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.

🧹 Nitpick comments (1)
packages/js-core/src/lib/common/tests/utils.test.ts (1)

1002-1024: Nit: verbose type cast for mock assignment.

The existing tests in this file (e.g., Line 864, 889) assign mocks directly without the as unknown as { ... } cast:

targetElement.matches = vi.fn(() => true);

The new tests use a heavier pattern:

(icon as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => false);

Both work, but using the simpler pattern would be consistent with the rest of the file.

♻️ Simplify mock assignments
-      (icon as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => false);
-      (icon as unknown as { closest: ReturnType<typeof vi.fn> }).closest = vi.fn(() => button);
+      icon.matches = vi.fn(() => false);
+      icon.closest = vi.fn(() => button);

Apply the same simplification to the other two new tests as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/js-core/src/lib/common/tests/utils.test.ts` around lines 1002 -
1024, Replace the verbose "as unknown as { matches: ReturnType<typeof vi.fn> }"
style casts used to assign mocks in the new tests with the simpler direct
assignment pattern used elsewhere in the file (e.g., targetElement.matches =
vi.fn(() => true)); specifically update the mock assignments for matches and
closest on the icon/button elements in the tests that call
evaluateNoCodeConfigClick so they use direct assignment (icon.matches =
vi.fn(...); icon.closest = vi.fn(...);) and apply the same simplification to the
other two new tests for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/js-core/src/lib/common/tests/utils.test.ts`:
- Around line 1002-1024: Replace the verbose "as unknown as { matches:
ReturnType<typeof vi.fn> }" style casts used to assign mocks in the new tests
with the simpler direct assignment pattern used elsewhere in the file (e.g.,
targetElement.matches = vi.fn(() => true)); specifically update the mock
assignments for matches and closest on the icon/button elements in the tests
that call evaluateNoCodeConfigClick so they use direct assignment (icon.matches
= vi.fn(...); icon.closest = vi.fn(...);) and apply the same simplification to
the other two new tests for consistency.

@bharathkumar39293
Copy link
Contributor Author

Hi @Dhruwang ,

This fixes nested click target matching in evaluateNoCodeConfigClick by adding a closest() fallback when the direct target doesn’t match the selector.

All unit tests are passing (232 total), and I added regression tests covering:

child click inside matched element

no matching ancestor

direct match fast path

Could you please review when you get a chance?
Thanks.

@Dhruwang
Copy link
Member

Hey @bharathkumar39293

Thanks for the PR. Could you share an example of a scenario where this issue would occur? As far as I know, this hasn’t been reported before.

@bharathkumar39293
Copy link
Contributor Author

bharathkumar39293 commented Feb 25, 2026

Hi @Dhruwang, happy to clarify!

The bug reproduces any time a click action's CSS selector targets an element that contains child elements (which is nearly every real-world button).

Minimal reproduction:

In Formbricks, create a no-code click action with CSS selector: .feedback-btn
On your website, add this button:

<button class="feedback-btn">
  <svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
    <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
  </svg>
  Give Feedback
</button>

Click the SVG icon (left side of the button) → Survey does not trigger ❌
Click the text "Give Feedback" (right side of the button) → Survey does not trigger ❌ (clicking a child)
Click the very edge of the button where no child element is → Survey triggers ✅ (only works on the 1-2px button padding)
Why it likely hasn't been reported explicitly: Users experience this as "the survey trigger doesn't work reliably" or "only works sometimes" — they don't know about event.target vs CSS selectors, so they assume it's a configuration issue and give up. The bug is silent (no console error).

This is especially common with icon buttons (virtually every design system — shadcn, Radix, MUI — uses or inside buttons).

Happy to record a short screen demo if that would help!

Copy link
Member

@Dhruwang Dhruwang left a comment

Choose a reason for hiding this comment

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

Looks good 🚀

@Dhruwang Dhruwang added this pull request to the merge queue Feb 27, 2026
Merged via the queue into formbricks:main with commit aecf858 Feb 27, 2026
24 of 28 checks passed
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.

Dynamic button based survey triggers are not working

2 participants