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);