From 1ad62f6a81dd518cb8159bc4ced2a9946be14397 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 28 Oct 2026 07:01:19 +0100 Subject: [PATCH] fix(226629): tooltips survive SPA/Turbo navigation; hotspot no longer shifts its element (226620) #226629: hotspot tooltips only appeared after a full reload. - The MutationObserver watched document.body; Turbo Drive swaps in a new on every visit, so new pages were never seen. Observe document.documentElement instead. - On a URL change only the nodes added in that same batch were checked, so a target rendered before the URL flipped was missed. Re-run the page rules and scan the page on every URL change (also polled every second), and remove tooltips whose page rules no longer match. - Elements that still carried our marker (Turbo page snapshot, or a re-render that wiped the hotspot) were skipped forever. Re-link them; drop dead popup copies from restored snapshots. - destroy() (run on every session change) stopped the observer for good. load() restarts it; no rAF wait needed for documentElement. #226620: the hotspot anchor was inserted as the element's first child as a floated inline-block, so flex/grid gaps, sibling margins (space-x) and :first-child styles pushed the element's content right. The anchor is now an absolutely positioned 0x0 box appended last, and the hotspot sits at the content's right edge (our anchor excluded from the measurement), so it no longer overlaps the text. Also: an explicit Offset X of 0 was replaced by the 4px default. Co-Authored-By: Claude Opus 5.5 --- src/GleapTooltipManager.js | 354 ++++++++++++++++++++++++++++--------- src/UI.js | 12 +- 2 files changed, 276 insertions(+), 90 deletions(-) diff --git a/src/GleapTooltipManager.js b/src/GleapTooltipManager.js index d5bdb96e..31774c23 100644 --- a/src/GleapTooltipManager.js +++ b/src/GleapTooltipManager.js @@ -10,6 +10,8 @@ export default class GleapTooltipManager { lastUrl = null; elementToFloatingUIMap = new WeakMap(); elementToTooltipMap = new WeakMap(); + anchorToElementMap = new WeakMap(); + livePopups = new WeakSet(); nextId = 0; // GleapTooltipManager singleton @@ -25,6 +27,19 @@ export default class GleapTooltipManager { // Recursive function to process each node and its children processNodeInsertion(node) { if (node.nodeType === Node.ELEMENT_NODE) { + // Our own hotspot anchors never carry a tooltip. + if (node.hasAttribute('data-gleap-tooltip-anchor')) { + return; + } + + // Copy of a tooltip popup brought back with a cached page snapshot: not ours anymore. + if (node.classList.contains('gleap-tooltip')) { + if (!this.livePopups.has(node)) { + node.remove(); + } + return; + } + // Check the node itself this.checkNodeTooltip(node); @@ -51,9 +66,10 @@ export default class GleapTooltipManager { start() { const self = this; - if (!window) { + if (typeof window === 'undefined' || this.started) { return; } + this.started = true; const throttle = (fn, time) => { let timeout = null; @@ -69,15 +85,18 @@ export default class GleapTooltipManager { }; }; - const handleResizeThrottled = throttle(self.updateHotspotPositions.bind(self), 250); - window.addEventListener('resize', handleResizeThrottled); + this.resizeListener = throttle(self.updateHotspotPositions.bind(self), 250); + window.addEventListener('resize', this.resizeListener); + + // Client-side navigation does not always touch the DOM after the URL flips + // (or the new page rendered before it), so watch the URL on its own as well. + this.urlCheckInterval = setInterval(() => { + self.checkUrlChange(); + }, 1000); - window.requestAnimationFrame(() => { + if (!this.observer && typeof MutationObserver !== 'undefined' && document.documentElement) { this.observer = new MutationObserver((mutations) => { - if (self.lastUrl !== window.location.href) { - self.lastUrl = window.location.href; - self.filteredTooltips = self.getFilteredTooltips(); - } + self.checkUrlChange(); mutations.forEach((mutation) => { // Check for attribute changes that might affect hotspot positioning @@ -101,37 +120,124 @@ export default class GleapTooltipManager { } }); - // Check for removed nodes + // Check for removed nodes (a node that was only moved is still connected). mutation.removedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE && this.elementToFloatingUIMap.has(node)) { - const floatingUI = this.elementToFloatingUIMap.get(node); - if (floatingUI) { - if (floatingUI.tooltip) { - floatingUI.tooltip.remove(); - } - floatingUI.cleanup(); - - this.elementToFloatingUIMap.delete(node); - } - } - - if (this.elementToTooltipMap.has(node)) { - this.elementToTooltipMap.delete(node); + if (node.nodeType === Node.ELEMENT_NODE && !node.isConnected) { + self.processNodeRemoval(node); } }); }); }); - // Start observing the document for changes. - this.observer.observe(document.body, { + // Observe the whole document, not just the current : Turbo Drive and + // similar routers swap in a brand-new on every visit. + this.observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'], }); + } + } + + // Re-evaluates the page rules when the URL changed (client-side navigation). + checkUrlChange() { + if (this.lastUrl === window.location.href) { + return; + } + this.lastUrl = window.location.href; + this.filteredTooltips = this.getFilteredTooltips(); + + // Remove tooltips whose page rules no longer match the new page. + const linkedElements = document.querySelectorAll('[data-gleap-tooltip]'); + linkedElements.forEach((element) => { + const link = this.elementToFloatingUIMap.get(element); + if (link && this.filteredTooltips.indexOf(link.data) === -1) { + this.unlinkElement(element); + } + }); + + // The target may have rendered before the URL changed, so scan the whole page. + this.checkForTooltips(); + } + + processNodeRemoval(node) { + // Linked elements that left the page. + const removedElements = Array.from(node.querySelectorAll('[data-gleap-tooltip]')); + if (node.hasAttribute('data-gleap-tooltip')) { + removedElements.push(node); + } + removedElements.forEach((element) => { + if (this.elementToFloatingUIMap.has(element)) { + this.unlinkElement(element); + } + }); + + // A hotspot wiped by a re-render (e.g. the framework replaced the element's text) + // while the element itself stayed on the page: put it back. + const removedAnchors = Array.from(node.querySelectorAll('[data-gleap-tooltip-anchor]')); + if (node.hasAttribute('data-gleap-tooltip-anchor')) { + removedAnchors.push(node); + } + removedAnchors.forEach((anchor) => { + const element = this.anchorToElementMap.get(anchor); + const link = element ? this.elementToFloatingUIMap.get(element) : null; + if (element && element.isConnected && link && link.anchor === anchor) { + const tooltip = link.data; + this.unlinkElement(element); + if (this.filteredTooltips.indexOf(tooltip) !== -1) { + this.linkTooltip(element, tooltip); + } + } }); } + // Whether the element has a working tooltip (not just our marker attribute). + isLinked(element) { + const link = this.elementToFloatingUIMap.get(element); + if (!link) { + return false; + } + if (link.anchor && !link.anchor.isConnected) { + return false; + } + return !!(link.tooltip && link.tooltip.isConnected); + } + + unlinkElement(element) { + const link = this.elementToFloatingUIMap.get(element); + if (link) { + try { + link.cleanup(); + } catch (exp) {} + if (link.tooltip) { + link.tooltip.remove(); + } + if (link.anchor) { + link.anchor.remove(); + } + this.elementToFloatingUIMap.delete(element); + } + + // Hotspot anchors copied along with the element (e.g. from a cached page snapshot). + const tooltipId = element.getAttribute('data-gleap-tooltip'); + if (tooltipId !== null) { + Array.from(element.children).forEach((child) => { + if (child.getAttribute('data-gleap-tooltip-anchor') === tooltipId) { + child.remove(); + } + }); + const sibling = element.nextElementSibling; + if (sibling && sibling.getAttribute('data-gleap-tooltip-anchor') === tooltipId) { + sibling.remove(); + } + } + + element.removeAttribute('data-gleap-tooltip'); + element.removeAttribute('data-gleap-tooltip-mode'); + this.elementToTooltipMap.delete(element); + } + updateHotspotPositions() { const elements = document.querySelectorAll(`[data-gleap-tooltip-mode='hotspot']`); elements.forEach((element) => { @@ -155,6 +261,7 @@ export default class GleapTooltipManager { `; document.body.appendChild(tooltip); + this.livePopups.add(tooltip); // Create a Popper instance with arrow and offset @@ -231,7 +338,13 @@ export default class GleapTooltipManager { tooltip.addEventListener('click', hide); return { - cleanup, + cleanup: () => { + cleanup(); + clearTimeout(hideTimeout); + element.removeEventListener('mouseenter', show); + element.removeEventListener('mouseleave', hide); + element.removeEventListener('click', hide); + }, tooltip, }; } @@ -243,56 +356,85 @@ export default class GleapTooltipManager { } linkTooltip = (element, tooltip) => { - if (element.hasAttribute('data-gleap-tooltip')) { + if (!element) { return; } + if (element.hasAttribute('data-gleap-tooltip')) { + if (this.isLinked(element)) { + return; + } + + // Only our marker is left (page snapshot restored by the router, or the + // hotspot was wiped by a re-render): set the tooltip up again. + this.unlinkElement(element); + } + const nextId = this.nextId++; element.setAttribute('data-gleap-tooltip', nextId); - if (element) { - var tooltipElem = null; - - if (tooltip.mode === 'hotspot') { - // Create hotspot container. - const hotspotContainer = document.createElement('div'); - hotspotContainer.setAttribute('data-gleap-tooltip-anchor', nextId); - hotspotContainer.classList.add('gleap-tooltip-anchor'); - element.setAttribute('data-gleap-tooltip-mode', 'hotspot'); - - if (this.canEmbed(element)) { - if (element.firstChild) { - element.insertBefore(hotspotContainer, element.firstChild); - } else { - element.appendChild(hotspotContainer); - } - } else { - element.parentNode.insertBefore(hotspotContainer, element.nextSibling); - } - // Create hotspot. - const hotspot = document.createElement('div'); - hotspot.classList.add('gleap-tooltip-hotspot'); - hotspot.setAttribute('data-gleap-tooltip-hotspot', nextId); + var tooltipElem = null; + var hotspotContainer = null; + + if (tooltip.mode === 'hotspot') { + // Create hotspot container. It is absolutely positioned and added as the last + // child, so the element's own layout (flex/grid gaps, sibling margins, + // :first-child styles, text flow) stays exactly as it was. + hotspotContainer = document.createElement('div'); + hotspotContainer.setAttribute('data-gleap-tooltip-anchor', nextId); + hotspotContainer.classList.add('gleap-tooltip-anchor'); + const anchorStyles = { + position: 'absolute', + display: 'block', + float: 'none', + width: '0px', + height: '0px', + margin: '0px', + padding: '0px', + border: 'none', + overflow: 'visible', + }; + Object.keys(anchorStyles).forEach((key) => { + hotspotContainer.style.setProperty(key, anchorStyles[key], 'important'); + }); + element.setAttribute('data-gleap-tooltip-mode', 'hotspot'); - hotspot.innerHTML = `${loadIcon(tooltip.icon, tooltip.color)}${ - tooltip.animated - ? `
` - : '' - }`; - hotspotContainer.appendChild(hotspot); + if (this.canEmbed(element)) { + element.appendChild(hotspotContainer); + } else if (element.parentNode) { + element.parentNode.insertBefore(hotspotContainer, element.nextSibling); + } - // Update hotspot position. - this.repositionHotspot(element, tooltip); + // Create hotspot. + const hotspot = document.createElement('div'); + hotspot.classList.add('gleap-tooltip-hotspot'); + hotspot.setAttribute('data-gleap-tooltip-hotspot', nextId); + + hotspot.innerHTML = `${loadIcon(tooltip.icon, tooltip.color)}${ + tooltip.animated + ? `
` + : '' + }`; + hotspotContainer.appendChild(hotspot); + + tooltipElem = hotspot; + this.elementToTooltipMap.set(element, tooltip); + this.anchorToElementMap.set(hotspotContainer, element); + } else { + tooltipElem = element; + } - tooltipElem = hotspot; - this.elementToTooltipMap.set(element, tooltip); - } else { - tooltipElem = element; - } + const floatingUIInstance = this.createTooltip(tooltipElem, tooltip.html, tooltip); - const floatingUIInstance = this.createTooltip(tooltipElem, tooltip.html, tooltip); + this.elementToFloatingUIMap.set(element, { + ...floatingUIInstance, + anchor: hotspotContainer, + data: tooltip, + }); - this.elementToFloatingUIMap.set(element, floatingUIInstance); + if (hotspotContainer) { + // Update hotspot position. + this.repositionHotspot(element, tooltip); } }; @@ -304,12 +446,26 @@ export default class GleapTooltipManager { } // Remove the window resize event listener if it was stored. - // (In start() you can assign the listener to a class property, e.g., this.resizeListener.) if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); this.resizeListener = null; } + if (this.urlCheckInterval) { + clearInterval(this.urlCheckInterval); + this.urlCheckInterval = null; + } + + // Stop floating UI updates and hover listeners of linked elements. + document.querySelectorAll('[data-gleap-tooltip]').forEach((element) => { + const link = this.elementToFloatingUIMap.get(element); + if (link) { + try { + link.cleanup(); + } catch (exp) {} + } + }); + // Remove all tooltip elements created by Gleap. const tooltipElements = document.querySelectorAll('.gleap-tooltip'); tooltipElements.forEach((tooltip) => tooltip.remove()); @@ -331,8 +487,14 @@ export default class GleapTooltipManager { // Clear internal references. this.elementToFloatingUIMap = new WeakMap(); this.elementToTooltipMap = new WeakMap(); + this.anchorToElementMap = new WeakMap(); + this.livePopups = new WeakSet(); this.tooltips = []; this.filteredTooltips = []; + this.lastUrl = null; + + // load() starts watching the page again. + this.started = false; } repositionHotspot(element, tooltip) { @@ -340,12 +502,13 @@ export default class GleapTooltipManager { return; } - const tooltipId = element.getAttribute('data-gleap-tooltip'); - if (!tooltipId) { + const link = this.elementToFloatingUIMap.get(element); + const anchorElement = link ? link.anchor : null; + if (!anchorElement) { return; } - const hotspot = document.querySelector(`[data-gleap-tooltip-hotspot="${tooltipId}"]`); + const hotspot = anchorElement.querySelector('[data-gleap-tooltip-hotspot]'); if (!hotspot) { return; } @@ -356,27 +519,35 @@ export default class GleapTooltipManager { if (!tooltip.posY) { tooltip.posY = 'center'; } - if (!tooltip.offsetX) { - tooltip.offsetX = 4; - } - if (!tooltip.offsetY) { - tooltip.offsetY = 0; - } - - var elementRect = element.getBoundingClientRect(); + // An explicit 0 is a valid offset; only fall back when none is set. + tooltip.offsetX = this.toOffset(tooltip.offsetX, 4); + tooltip.offsetY = this.toOffset(tooltip.offsetY, 0); + + const rect = element.getBoundingClientRect(); + const elementRect = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; try { - // Improve width calculation for text elements. - var range = document.createRange(); - range.selectNodeContents(element); - const style = window.getComputedStyle(element); - const fixedWidth = range.getBoundingClientRect().width + parseFloat(style.paddingLeft); - if (fixedWidth < elementRect.width) { - elementRect.width = fixedWidth; + // Text elements are often wider than their text: place the hotspot next to + // the content's right edge instead (our own anchor excluded). + if (anchorElement.parentNode === element) { + const range = document.createRange(); + range.setStart(element, 0); + range.setEndBefore(anchorElement); + const contentRect = range.getBoundingClientRect(); + if (contentRect && contentRect.width > 0) { + const contentWidth = contentRect.right - elementRect.left; + if (contentWidth > 0 && contentWidth < elementRect.width) { + elementRect.width = contentWidth; + } + } } } catch (exp) {} - const anchorElement = document.querySelector(`[data-gleap-tooltip-anchor="${tooltipId}"]`); const anchorRect = anchorElement.getBoundingClientRect(); // Offset calculation for hotspot position. @@ -415,7 +586,16 @@ export default class GleapTooltipManager { } } + toOffset(value, fallback) { + if (value === null || value === undefined || value === '') { + return fallback; + } + const number = Number(value); + return isFinite(number) ? number : fallback; + } + updateFilteredTooltips = () => { + this.lastUrl = window.location.href; this.filteredTooltips = this.getFilteredTooltips(); this.checkForTooltips(); }; @@ -465,6 +645,10 @@ export default class GleapTooltipManager { load = () => { const self = this; + + // destroy() stops watching the page; make sure we watch it again. + this.start(); + const sessionInstance = GleapSession.getInstance(); const http = new XMLHttpRequest(); diff --git a/src/UI.js b/src/UI.js index 74df2b1e..bdf22d54 100644 --- a/src/UI.js +++ b/src/UI.js @@ -278,11 +278,13 @@ export const injectStyledCSS = ( } .gleap-tooltip-anchor { - position: relative; - display: inline-block; - float: left; - max-width: 0px; - width: 17px; + position: absolute; + display: block; + width: 0px; + height: 0px; + margin: 0px; + padding: 0px; + overflow: visible; } .gleap-tooltip-hotspot {