Compare commits

...

4 Commits

Author SHA1 Message Date
Dhruwang
ddeef4096f fix: test 2026-02-26 18:39:42 +05:30
Dhruwang
9fe4678c47 refactor: improve error handling in wrapThrows and wrapThrowsAsync functions; simplify CSS selector matching logic 2026-02-26 18:10:18 +05:30
Dhruwang
49acc1cbb8 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/nested-click-target-delegate 2026-02-26 16:02:49 +05:30
bharathkumar39293
e29300df2c fix(js-core): use closest() fallback for nested click target matching
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: https://github.com/formbricks/formbricks/issues/7314
2026-02-22 07:49:20 +06:00
2 changed files with 99 additions and 10 deletions

View File

@@ -887,6 +887,7 @@ describe("utils.ts", () => {
targetElement.className = "other";
targetElement.matches = vi.fn(() => false);
targetElement.closest = vi.fn(() => null); // no ancestor matches either
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
@@ -993,13 +994,93 @@ describe("utils.ts", () => {
expect(result).toBe(true);
});
// --- Regression tests for nested child click target (issue #7314) ---
// In this test environment document.createElement() returns a plain mock object,
// so we set .matches and .closest as vi.fn() — the same pattern used by existing tests.
// This exercises the exact code path of the fix: matches() fails → closest() succeeds.
test("returns true when clicking a child element inside a button matched by cssSelector", () => {
const button = document.createElement("button");
const icon = document.createElement("span");
// Simulate: icon does NOT directly match ".my-btn", but its closest ancestor does
(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);
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};
// Before fix: matches() → false → returns false (bug)
// After fix: matches() → false → closest() → button → returns true (correct)
const result = evaluateNoCodeConfigClick(icon as unknown as HTMLElement, action);
expect(result).toBe(true);
});
test("returns false when clicking a child element with no matching ancestor", () => {
const other = document.createElement("div");
// Simulate: element doesn't match, and no ancestor matches either
(other as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => false);
(other as unknown as { closest: ReturnType<typeof vi.fn> }).closest = vi.fn(() => null);
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};
const result = evaluateNoCodeConfigClick(other as unknown as HTMLElement, action);
expect(result).toBe(false);
});
test("uses direct target (not closest) when target directly matches cssSelector", () => {
const button = document.createElement("button");
// Simulate: click on the button itself — matches() succeeds, closest() should NOT be called
(button as unknown as { matches: ReturnType<typeof vi.fn> }).matches = vi.fn(() => true);
const closestSpy = vi.fn();
(button as unknown as { closest: ReturnType<typeof vi.fn> }).closest = closestSpy;
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector: ".my-btn" },
},
};
const result = evaluateNoCodeConfigClick(button as unknown as HTMLElement, action);
expect(result).toBe(true);
expect(closestSpy).not.toHaveBeenCalled(); // closest() is only a fallback
});
test("handles multiple cssSelectors correctly", () => {
const targetElement = document.createElement("div");
targetElement.className = "test other";
targetElement.matches = vi.fn((selector) => {
return selector === ".test" || selector === ".other";
return selector === ".test" || selector === ".other" || selector === ".test .other";
});
targetElement.closest = vi.fn(() => null); // not needed but consistent with mock environment
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",

View File

@@ -304,20 +304,28 @@ export const evaluateNoCodeConfigClick = (
if (!innerHtml && !cssSelector) return false;
if (innerHtml && targetElement.innerHTML !== innerHtml) return false;
// Resolve the element to test: prefer the direct click target, but walk up to
// the nearest ancestor that matches the CSS selector (event delegation for nested markup,
// e.g. <svg> or <span> inside a <button class="my-btn">).
let matchedElement: HTMLElement = targetElement;
if (cssSelector) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelector
.split(/(?=[.#])/) // split before each . or #
.map((sel) => sel.trim()); // remove leftover whitespace
for (const selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;
}
let matchesDirectly = false;
try {
matchesDirectly = targetElement.matches(cssSelector);
} catch {
matchesDirectly = false;
}
if (!matchesDirectly) {
const ancestor = targetElement.closest(cssSelector);
if (!ancestor) return false;
matchedElement = ancestor as HTMLElement;
}
}
// Check innerHtml against the resolved element, not the raw click target
if (innerHtml && matchedElement.innerHTML !== innerHtml) return false;
const connector = action.noCodeConfig.urlFiltersConnector ?? "or";
const isValidUrl = handleUrlFilters(urlFilters, connector);