From a1168c6ae82a81b19a3bf08fc04f2e22a38a3192 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Fri, 9 Oct 2026 13:57:11 +0200 Subject: [PATCH] fix(185000): never throw on invalid stored tour/tooltip selectors A selector typed without brackets (data-x="y") made querySelector throw in the tour highlight; the colon-escape fallback in the catch threw again, so the tour died in its retry timer instead of ending via onElementNotFound. Copilot tours polled forever, tooltip matching threw on every DOM mutation and the builder's highlight/click messages threw too. GleapSafeSelector tries the stored selector, then the bracketed attribute form, then the colon-escaped id form, and resolves to nothing otherwise. Co-Authored-By: Claude Opus 5.5 --- src/GleapAdminManager.js | 5 +-- src/GleapCopilotTours.js | 5 +-- src/GleapSafeSelector.js | 68 ++++++++++++++++++++++++++++++++++++++ src/GleapTooltipManager.js | 5 +-- src/GleapTours.js | 22 ++++-------- 5 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 src/GleapSafeSelector.js diff --git a/src/GleapAdminManager.js b/src/GleapAdminManager.js index a85674cd..1feba577 100644 --- a/src/GleapAdminManager.js +++ b/src/GleapAdminManager.js @@ -2,6 +2,7 @@ import { loadIcon } from './UI'; import GleapAdminHelper from './GleapAdminHelper'; import Gleap, { GleapModalManager, GleapProductTours, GleapBannerManager } from './Gleap'; import { isGleapAdminOrigin, resolveGleapAdminOrigin } from './GleapAdminOrigins'; +import { querySelectorSafe } from './GleapSafeSelector'; export default class GleapAdminManager { libraryInstance = null; @@ -158,7 +159,7 @@ export default class GleapAdminManager { // Find the element and highlight it. if (data?.data?.selector) { - const element = document.querySelector(data?.data?.selector); + const element = querySelectorSafe(data?.data?.selector); if (element) { element.classList.add('gleap-admin-highlight'); } @@ -202,7 +203,7 @@ export default class GleapAdminManager { if (data.name === 'click') { try { - document.querySelector(data.data.selector).click(); + querySelectorSafe(data.data.selector)?.click(); } catch (e) { console.log(e); } diff --git a/src/GleapCopilotTours.js b/src/GleapCopilotTours.js index 9711f738..07efe6c0 100644 --- a/src/GleapCopilotTours.js +++ b/src/GleapCopilotTours.js @@ -1,6 +1,7 @@ import { GleapConfigManager, GleapTranslationManager } from './Gleap'; import { applyGleapCSPNonce } from './GleapHelper'; import { typeIntoElement } from './GleapInputFiller'; +import { querySelectorSafe } from './GleapSafeSelector'; import { calculateContrast, loadIcon } from './UI'; const localStorageKey = 'gleap-tour-data'; @@ -90,7 +91,7 @@ function waitForElement(selector, timeout = 5000) { let attempts = 0; return new Promise((resolve, reject) => { const interval = setInterval(() => { - const element = document.querySelector(selector); + const element = querySelectorSafe(selector); if (element) { clearInterval(interval); resolve(element); @@ -204,7 +205,7 @@ export default class GleapCopilotTours { const steps = self.productTourData.steps; const currentStep = steps[self.currentActiveIndex]; if (currentStep && currentStep.selector && currentStep.selector !== '') { - self.updatePointerPosition(document.querySelector(currentStep.selector), currentStep); + self.updatePointerPosition(querySelectorSafe(currentStep.selector), currentStep); } } }); diff --git a/src/GleapSafeSelector.js b/src/GleapSafeSelector.js new file mode 100644 index 00000000..c0210ab7 --- /dev/null +++ b/src/GleapSafeSelector.js @@ -0,0 +1,68 @@ +// `data-pf="x"` typed into the selector field instead of `[data-pf="x"]`. +const BARE_ATTRIBUTE_SELECTOR = /^[A-Za-z_][\w-]*\s*[~|^$*]?=\s*(?:"[^"]*"|'[^']*'|[^\s"'\]]+)$/; + +/** + * Selectors a stored tour step may hold, most literal first. + * + * Steps can carry hand typed text, so besides the stored string this tries the + * two mistakes seen in the wild: an attribute without its brackets and an id + * containing unescaped colons. + */ +const selectorVariants = (selector) => { + const trimmed = selector.trim(); + const variants = [trimmed]; + if (BARE_ATTRIBUTE_SELECTOR.test(trimmed)) { + variants.push(`[${trimmed}]`); + } + variants.push(trimmed.replace(/(#[^#\s]+)/g, (match) => match.replace(/:/g, '\\:'))); + return variants; +}; + +// Tooltips test selectors on every DOM mutation; warn about each one once. +const warnedSelectors = {}; + +const runFirstValid = (selector, fn) => { + if (typeof selector !== 'string' || !selector.trim()) { + return null; + } + + const variants = selectorVariants(selector); + for (let i = 0; i < variants.length; i++) { + try { + return { result: fn(variants[i]) }; + } catch (e) {} + } + + if (!warnedSelectors[selector]) { + warnedSelectors[selector] = true; + try { + console.warn('[Gleap] Invalid selector:', selector); + } catch (e) {} + } + return null; +}; + +/** + * `document.querySelector` for stored selectors. Never throws: an invalid + * selector resolves to `null`, like one that matches nothing. + */ +export const querySelectorSafe = (selector, root = document) => { + const found = runFirstValid(selector, (variant) => root.querySelector(variant)); + return found ? found.result : null; +}; + +/** + * `document.querySelectorAll` for stored selectors. Never throws. + */ +export const querySelectorAllSafe = (selector, root = document) => { + const found = runFirstValid(selector, (variant) => root.querySelectorAll(variant)); + return found ? found.result : []; +}; + +/** + * `element.matches` for stored selectors. Never throws. + */ +export const matchesSelectorSafe = (element, selector) => { + const found = runFirstValid(selector, (variant) => element.matches(variant)); + return found ? found.result : false; +}; diff --git a/src/GleapTooltipManager.js b/src/GleapTooltipManager.js index 5532c1a4..d5bdb96e 100644 --- a/src/GleapTooltipManager.js +++ b/src/GleapTooltipManager.js @@ -2,6 +2,7 @@ import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floati import { GleapSession } from './Gleap'; import { loadIcon } from './UI'; import { checkPageFilter, checkPageRules } from './GleapPageFilter'; +import { matchesSelectorSafe, querySelectorAllSafe } from './GleapSafeSelector'; export default class GleapTooltipManager { tooltips = []; @@ -40,7 +41,7 @@ export default class GleapTooltipManager { checkNodeTooltip(node) { if (this.filteredTooltips.length > 0) { this.filteredTooltips.forEach((tooltip) => { - if (tooltip.selector && node.matches(tooltip.selector)) { + if (tooltip.selector && matchesSelectorSafe(node, tooltip.selector)) { this.linkTooltip(node, tooltip); } }); @@ -449,7 +450,7 @@ export default class GleapTooltipManager { const tooltip = this.filteredTooltips[i]; try { - const elements = document.querySelectorAll(tooltip.selector); + const elements = querySelectorAllSafe(tooltip.selector); for (let j = 0; j < elements.length; j++) { const element = elements[j]; diff --git a/src/GleapTours.js b/src/GleapTours.js index 30a83af7..df773488 100644 --- a/src/GleapTours.js +++ b/src/GleapTours.js @@ -1,3 +1,5 @@ +import { querySelectorSafe } from './GleapSafeSelector'; + const GleapTours = (function () { 'use strict'; let currentConfig = {}; @@ -234,15 +236,9 @@ const GleapTours = (function () { const { element } = step; let elemObj = element; if (typeof elemObj === 'string') { - try { - elemObj = document.querySelector(element); - } catch (error) { - // This will escape colons within IDs but not affect pseudo-classes or other valid uses of colons - let refactoredElement = element.replace(/(#[^#\s]+)/g, function (match) { - return match.replace(/:/g, '\\:'); - }); - elemObj = document.querySelector(refactoredElement); - } + // Stored selectors can be hand typed and invalid; that has to end in + // onElementNotFound below instead of an exception inside the retry timer. + elemObj = querySelectorSafe(element); } if (element && !elemObj && attemptTime >= 0) { setTimeout(() => { @@ -277,13 +273,7 @@ const GleapTours = (function () { // Re-query the element if the cached reference is no longer in the DOM. if (!document.body.contains(activeHighlight) && activeStep.element && typeof activeStep.element === 'string') { try { - var freshEl = document.querySelector(activeStep.element); - if (!freshEl) { - var escaped = activeStep.element.replace(/(#[^#\s]+)/g, function (match) { - return match.replace(/:/g, '\\:'); - }); - freshEl = document.querySelector(escaped); - } + var freshEl = querySelectorSafe(activeStep.element); if (freshEl) { activeHighlight = freshEl; setState('__activeElement', freshEl);