Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions src/components/Tooltip/anchor-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import parseDataTooltipIdSelector from '../../utils/parse-data-tooltip-id-selector'

type AnchorRegistrySubscriber = (anchors: Element[], error: Error | null) => void

type AnchorRegistryEntry = {
Expand All @@ -16,15 +18,6 @@ const registry = new Map<string, AnchorRegistryEntry>()

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
Expand Down Expand Up @@ -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)
}
Expand Down
53 changes: 53 additions & 0 deletions src/test/escaped-anchor-selectors.spec.js
Original file line number Diff line number Diff line change
@@ -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)
},
)
})
6 changes: 5 additions & 1 deletion src/utils/parse-data-tooltip-id-selector.ts
Original file line number Diff line number Diff line change
@@ -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
}

Expand Down