From 03b0ef3a6302dac517ff717f231d879c3e0efe43 Mon Sep 17 00:00:00 2001 From: Ferit Date: Wed, 14 Oct 2026 15:00:52 +0300 Subject: [PATCH 1/2] fix: keep escaped anchor selectors updating --- src/components/Tooltip/anchor-registry.ts | 13 ++--- src/test/escaped-anchor-selectors.spec.js | 53 +++++++++++++++++++++ src/utils/parse-data-tooltip-id-selector.ts | 2 +- 3 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 src/test/escaped-anchor-selectors.spec.js 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..d3eb5fb1 100644 --- a/src/utils/parse-data-tooltip-id-selector.ts +++ b/src/utils/parse-data-tooltip-id-selector.ts @@ -1,7 +1,7 @@ function parseDataTooltipIdSelector(selector: string) { const match = selector.match(/^\[data-tooltip-id=(['"])((?:\\.|(?!\1).)*)\1\]$/) - if (!match) { + if (!match || /\\(?!['"])/.test(match[2])) { return null } From 3dba97793d7bebb2367cdc9ad6e7a0903c8c5684 Mon Sep 17 00:00:00 2001 From: Ferit Date: Thu, 15 Oct 2026 17:42:13 +0300 Subject: [PATCH 2/2] refactor: extract escape regex constant and document selector variables --- src/utils/parse-data-tooltip-id-selector.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/parse-data-tooltip-id-selector.ts b/src/utils/parse-data-tooltip-id-selector.ts index d3eb5fb1..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 || /\\(?!['"])/.test(match[2])) { + // Detects escapes other than quotes that require native selector fallback. + const unsupportedEscapeRegex = /\\(?!['"])/ + + if (!match || unsupportedEscapeRegex.test(match[2])) { return null }