diff --git a/src/components/Tooltip/anchor-registry.ts b/src/components/Tooltip/anchor-registry.ts index ccab81e7..447acf4b 100644 --- a/src/components/Tooltip/anchor-registry.ts +++ b/src/components/Tooltip/anchor-registry.ts @@ -1,3 +1,5 @@ +import parseDataTooltipIdSelector from '../../utils/parse-data-tooltip-id-selector' + type AnchorRegistrySubscriber = (anchors: Element[], error: Error | null) => void type AnchorRegistryEntry = { @@ -16,15 +18,6 @@ const registry = new Map() let documentObserver: MutationObserver | null = null -/** - * Extract a tooltip ID from a simple `[data-tooltip-id='value']` selector. - * Returns null for complex or custom selectors. - */ -function extractTooltipId(selector: string): string | null { - const match = selector.match(/^\[data-tooltip-id=(['"])((?:\\.|(?!\1).)*)\1\]$/) - return match ? match[2].replace(/\\(['"])/g, '$1') : null -} - function areAnchorListsEqual(left: Element[], right: Element[]) { if (left.length !== right.length) { return false @@ -222,7 +215,7 @@ export function subscribeAnchorSelector(selector: string, subscriber: AnchorRegi anchors: initialState.anchors, error: initialState.error, subscribers: new Set(), - tooltipId: extractTooltipId(selector), + tooltipId: parseDataTooltipIdSelector(selector), } registry.set(selector, entry) } diff --git a/src/test/escaped-anchor-selectors.spec.js b/src/test/escaped-anchor-selectors.spec.js new file mode 100644 index 00000000..10ef0f1b --- /dev/null +++ b/src/test/escaped-anchor-selectors.spec.js @@ -0,0 +1,53 @@ +import { subscribeAnchorSelector, resetAnchorRegistry } from '../components/Tooltip/anchor-registry' +import { installMockMutationObserver } from './mutation-observer-test-utils' +import parseDataTooltipIdSelector from '../utils/parse-data-tooltip-id-selector' +import { act } from '@testing-library/react' + +describe('escaped anchor selectors', () => { + let controller + beforeEach(() => { + resetAnchorRegistry() + document.body.innerHTML = '' + controller = installMockMutationObserver() + }) + afterEach(() => { + resetAnchorRegistry() + controller.restore() + document.body.innerHTML = '' + }) + + test('uses native selector matching for hexadecimal and backslash escapes', () => { + expect(parseDataTooltipIdSelector("[data-tooltip-id='\\61 nchor']")).toBeNull() + expect(parseDataTooltipIdSelector("[data-tooltip-id='a\\\\b']")).toBeNull() + expect(parseDataTooltipIdSelector("[data-tooltip-id='a\\'b']")).toBe("a'b") + }) + + test.each([ + ["[data-tooltip-id='\\61 nchor']", 'anchor'], + ["[data-tooltip-id='a\\\\b']", 'a\\b'], + ])( + 'updates escaped selector %s when more than four selectors are registered', + async (selector, id) => { + const subscriber = jest.fn() + subscribeAnchorSelector(selector, subscriber) + for (let i = 0; i < 4; i++) { + subscribeAnchorSelector(`[data-tooltip-id='other-${i}']`, jest.fn()) + } + const anchor = document.createElement('button') + anchor.dataset.tooltipId = id + document.body.appendChild(anchor) + expect(document.querySelector(selector)).toBe(anchor) + controller.triggerAll([{ type: 'childList', addedNodes: [anchor], removedNodes: [] }]) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 32)) + }) + expect(subscriber).toHaveBeenLastCalledWith([anchor], null) + anchor.removeAttribute('data-tooltip-id') + controller.triggerAll([{ type: 'attributes', target: anchor, oldValue: id }]) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 32)) + }) + expect(subscriber).toHaveBeenLastCalledWith([], null) + }, + ) +}) diff --git a/src/utils/parse-data-tooltip-id-selector.ts b/src/utils/parse-data-tooltip-id-selector.ts index 5d6b4ae9..b00e5d99 100644 --- a/src/utils/parse-data-tooltip-id-selector.ts +++ b/src/utils/parse-data-tooltip-id-selector.ts @@ -1,7 +1,11 @@ function parseDataTooltipIdSelector(selector: string) { + // Extracts the quote character and raw attribute value from a [data-tooltip-id=...] selector. const match = selector.match(/^\[data-tooltip-id=(['"])((?:\\.|(?!\1).)*)\1\]$/) - if (!match) { + // Detects escapes other than quotes that require native selector fallback. + const unsupportedEscapeRegex = /\\(?!['"])/ + + if (!match || unsupportedEscapeRegex.test(match[2])) { return null }