Bootstrap v6 — AST Pattern Analysis Report

Structural code patterns found via ast-grep (complements ESLint report)

These are not errors — they are opportunities for modernization that require human review

73 Matches
13 Patterns
20 Files affected

Modern JS Operators

typeof x !== 'undefined' checks

ast-grep needs review 3

In modern JS, x != null covers both undefined and null. Or use optional chaining ?. / nullish coalescing ?? for safe access.

MDN Documentation →
Before (current)
typeof x !== 'undefined'
After (modern)
x !== undefined
— or —
x?.prop ?? fallback
FileLineCurrent codeSuggested fix
dom/event-handler.js 231:9 if (typeof callable !== 'undefined') { if (callable !== undefined) {
util/index.js 79:10 return typeof object.nodeType !== 'undefined' return object.nodeType !== undefined
util/index.js 130:7 if (typeof element.disabled !== 'undefined') { if (element.disabled !== undefined) {

Object.keys() for iteration

ast-grep needs review 6

When iterating object entries, Object.entries() gives both key and value. Object.keys(x).includes(y) can be Object.hasOwn(x, y).

MDN Documentation →
Before (current)
for (const key of Object.keys(obj)) { ... }
Object.keys(obj).includes(name)
After (modern)
for (const [key, val] of Object.entries(obj)) { ... }
Object.hasOwn(obj, name)
FileLineCurrent codeSuggested fix
util/floating-ui.js 108:28 for (const breakpoint of Object.keys(BREAKPOINTS)) {
dom/manipulator.js 55:20 const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'))
util/sanitizer.js 100:10 if (!Object.keys(allowList).includes(elementName)) { if (!Object.hasOwn(allowList, elementName)) {
dom/event-handler.js 233:12 if (!Object.keys(storeElementEvent).length) {
dom/event-handler.js 242:34 for (const elementEvent of Object.keys(events)) {
tooltip.js 654:33 for (const dataAttribute of Object.keys(dataAttributes)) {

Object.keys(x).length for empty check

ast-grep needs review 1

Checking Object.keys(x).length allocates an array just to count properties. This is a minor performance consideration — acceptable for infrequent calls, but worth noting in hot paths.

Before (current)
if (!Object.keys(obj).length) { ... }
After (modern)
// Acceptable pattern, but allocates an array
// Consider extracting to a utility for hot paths
FileLineCurrent codeSuggested fix
dom/event-handler.js 233:12 if (!Object.keys(storeElementEvent).length) {

DOM Patterns

Object.assign() on element.style

ast-grep needs review 3

While Object.assign(el.style, {...}) works, consider if individual el.style.prop = val assignments or CSS class toggles would be cleaner. Note: cssText overwrites all inline styles, while Object.assign merges.

MDN Documentation →
Before (current)
Object.assign(el.style, { left: x, top: y })
After (modern)
el.style.left = `${x}px`
el.style.top = `${y}px`
— or CSS class toggle —
el.classList.add('positioned')
FileLineCurrent codeSuggested fix
tooltip.js 462:5 Object.assign(tip.style, { position: 'absolute', left: `${x}px`, top: `${y}px` })
tooltip.js 484:7 Object.assign(arrowElement.style, { left: isVertical && arrowX !== null ? `${arrowX}px` : '', top: !isVertical && arrowY !== null ? `${arrowY}px` : '', // Reset the other axis to let CSS handle it right: '', bottom: '' })
dropdown.js 469:5 Object.assign(floating.style, { position: 'absolute', left: `${x}px`, top: `${y}px`, margin: '0' })

element.className = ... assignment

ast-grep needs review 4

element.className = 'foo' replaces all classes. Prefer classList.add()/classList.toggle() for safer class manipulation, or classList.value for full replacement.

MDN Documentation →
Before (current)
el.className = 'my-class'
After (modern)
el.classList.add('my-class')
— or for full set —
el.classList.value = 'my-class'
FileLineCurrent codeSuggested fix
util/backdrop.js 114:7 backdrop.className = this._config.className backdrop.classList.value = this._config.className
chip-input.js 275:5 input.className = 'form-ghost' input.classList.value = 'form-ghost'
chip-input.js 307:5 chip.className = CLASS_NAME_CHIP chip.classList.value = CLASS_NAME_CHIP
chip-input.js 322:5 button.className = CLASS_NAME_CHIP_DISMISS button.classList.value = CLASS_NAME_CHIP_DISMISS

document.createElement() + manual setup

ast-grep needs review 5

Multiple createElement + className + innerHTML chains can be replaced with <template> elements or a single innerHTML string for cleaner DOM construction. Note: createElement is fast for simple elements — review on case-by-case basis.

MDN Documentation →
Before (current)
const el = document.createElement('div')
el.className = 'foo'
el.innerHTML = '...'
After (modern)
const tpl = `<div class="foo">...</div>`
container.insertAdjacentHTML('beforeend', tpl)
FileLineCurrent codeSuggested fix
util/template-factory.js 85:29 const templateWrapper = document.createElement('div')
util/backdrop.js 113:24 const backdrop = document.createElement('div')
chip-input.js 273:19 const input = document.createElement('input')
chip-input.js 306:18 const chip = document.createElement('span')
chip-input.js 320:20 const button = document.createElement('button')

innerHTML assignment

ast-grep needs review 4

innerHTML triggers full HTML parsing and can be an XSS vector. Consider textContent for text-only, or replaceChildren() + DOM nodes for structured content. Bootstrap sanitizes, but it is worth auditing.

MDN Documentation →
Before (current)
el.innerHTML = userContent
After (modern)
el.textContent = text        // for plain text
el.replaceChildren(...nodes)  // for DOM nodes
FileLineCurrent codeSuggested fix
util/template-factory.js 86:5 templateWrapper.innerHTML = this._maybeSanitize(this._config.template)
util/template-factory.js 134:7 templateElement.innerHTML = this._maybeSanitize(content)
util/template-factory.js 151:7 templateElement.innerHTML = '' templateElement.replaceChildren()
chip-input.js 325:5 button.innerHTML = this._config.dismissIcon

.parentNode usage

ast-grep needs review 10

parentNode can return non-Element nodes (Document, DocumentFragment). parentElement is safer when you expect an Element. Also consider .closest(selector) for ancestor traversal.

MDN Documentation →
Before (current)
element.parentNode
After (modern)
element.parentElement       // guaranteed Element|null
element.closest('selector') // ancestor lookup
FileLineCurrent codeSuggested fix
dom/selector-engine.js 50:20 let ancestor = element.parentNode.closest(selector) let ancestor = element.parentElement.closest(selector)
dom/selector-engine.js 54:18 ancestor = ancestor.parentNode.closest(selector) ancestor = ancestor.parentElement.closest(selector)
offcanvas.js 183:20 rootElement: this._element.parentNode, rootElement: this._element.parentElement,
util/index.js 109:20 if (summary && summary.parentNode !== closedDetails) { if (summary && summary.parentElement !== closedDetails) {
util/index.js 153:8 if (!element.parentNode) { if (!element.parentElement) {
util/index.js 157:25 return findShadowRoot(element.parentNode) return findShadowRoot(element.parentElement)
dom/event-handler.js 104:70 for (let { target } = event; target && target !== this; target = target.parentNode) { for (let { target } = event; target && target !== this; target = target.parentElement) {
dropdown.js 137:20 this._parent = this._element.parentNode // dropdown wrapper this._parent = this._element.parentElement // dropdown wrapper
dropdown.js 644:20 const parent = currentSubmenuWrapper.parentNode const parent = currentSubmenuWrapper.parentElement
dropdown.js 927:54 SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode)) SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentElement))

setAttribute() / removeAttribute() for ARIA properties

ast-grep needs review 16

ARIA IDL properties like el.ariaExpanded, el.ariaSelected, el.ariaLabel provide direct property access instead of string-based setAttribute(). Cleaner syntax and avoids string typos. Supported in Chrome 81+, Firefox 119+, Safari 12.1+.

MDN Documentation →
Before (current)
el.setAttribute('aria-expanded', 'true')
el.removeAttribute('aria-current')
After (modern)
el.ariaExpanded = 'true'
el.ariaCurrent = null
FileLineCurrent codeSuggested fix
button.js 37:5 this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE)) this._element.ariaPressed = this._element.classList.toggle(CLASS_NAME_ACTIVE)
offcanvas.js 110:5 this._element.setAttribute('aria-modal', true) this._element.ariaModal = true
collapse.js 250:7 element.setAttribute('aria-expanded', isOpen) element.ariaExpanded = isOpen
tab.js 120:7 element.setAttribute('aria-selected', true) element.ariaSelected = true
tab.js 146:7 element.setAttribute('aria-selected', false) element.ariaSelected = false
tab.js 199:5 child.setAttribute('aria-selected', isActive) child.ariaSelected = isActive
tab.js 244:5 outerElem.setAttribute('aria-expanded', open) outerElem.ariaExpanded = open
carousel.js 282:7 newActiveIndicator.setAttribute('aria-current', 'true') newActiveIndicator.ariaCurrent = 'true'
chip-input.js 323:5 button.setAttribute('aria-label', 'Remove') button.ariaLabel = 'Remove'
tooltip.js 223:5 this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
tooltip.js 606:7 this._element.setAttribute('aria-label', title) this._element.ariaLabel = title
dropdown.js 201:5 this._element.setAttribute('aria-expanded', 'true') this._element.ariaExpanded = 'true'
dropdown.js 258:5 this._element.setAttribute('aria-expanded', 'false') this._element.ariaExpanded = 'false'
dropdown.js 577:5 trigger.setAttribute('aria-expanded', 'true') trigger.ariaExpanded = 'true'
dropdown.js 578:5 trigger.setAttribute('aria-haspopup', 'true') trigger.ariaHaspopup = 'true'
dropdown.js 622:7 trigger.setAttribute('aria-expanded', 'false') trigger.ariaExpanded = 'false'

removeAttribute() for ARIA properties

ast-grep needs review 3

Removing ARIA attributes can be done via IDL property assignment. Setting the property to null removes the underlying attribute.

MDN Documentation →
Before (current)
el.removeAttribute('aria-current')
After (modern)
el.ariaCurrent = null
FileLineCurrent codeSuggested fix
offcanvas.js 146:7 this._element.removeAttribute('aria-modal') this._element.ariaModal = null
carousel.js 276:5 activeIndicator.removeAttribute('aria-current') activeIndicator.ariaCurrent = null
tooltip.js 294:7 this._element.removeAttribute('aria-describedby')

tagName comparison instead of matches()

ast-grep needs review 13

element.matches('selector') is more flexible and readable than comparing tagName. It handles case-insensitive matching and supports complex selectors.

MDN Documentation →
Before (current)
el.tagName === 'A'
/input|textarea/i.test(el.tagName)
After (modern)
el.matches('a')
el.matches('input, textarea')
FileLineCurrent codeSuggested fix
datepicker.js 171:21 this._isInput = this._element.tagName === 'INPUT' this._isInput = this._element.matches('input')
datepicker.js 458:7 if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true') { if (this.matches('input') || this.dataset.bsInline === 'true') {
datepicker.js 468:7 if (this.tagName !== 'INPUT') {
collapse.js 261:7 if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) { if (event.target.matches('a') || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
collapse.js 261:64 if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) { if (event.target.matches('a') || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
carousel.js 253:32 if (/input|textarea/i.test(event.target.tagName)) { event.target.matches('input, textarea')
util/component-functions.js 17:32 if (['A', 'AREA'].includes(this.tagName)) { this.matches('a, area')
util/component-functions.js 49:32 if (['A', 'AREA'].includes(this.tagName)) { this.matches('a, area')
offcanvas.js 217:30 if (['A', 'AREA'].includes(this.tagName)) { this.matches('a, area')
dialog.js 229:30 if (['A', 'AREA'].includes(this.tagName)) { this.matches('a, area')
tab.js 273:30 if (['A', 'AREA'].includes(this.tagName)) { this.matches('a, area')
dropdown.js 887:147 if (context._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) { event.target.matches('input, select, option, textarea, form')
dropdown.js 903:44 const isInput = /input|textarea/i.test(event.target.tagName) event.target.matches('input, textarea')

Event Handling

addEventListener / removeEventListener pairs

ast-grep needs review 3

Manual removeEventListener requires keeping a reference to the handler. AbortController + { signal } option provides cleaner lifecycle management — call controller.abort() to remove all listeners at once.

MDN Documentation →
Before (current)
el.addEventListener('click', handler)
// later...
el.removeEventListener('click', handler)
After (modern)
const ac = new AbortController()
el.addEventListener('click', handler, { signal: ac.signal })
// later...
ac.abort() // removes all listeners
FileLineCurrent codeSuggested fix
util/floating-ui.js 126:5 mql.removeEventListener('change', handler)
util/index.js 216:5 transitionElement.removeEventListener(TRANSITION_END, handler)
dom/event-handler.js 191:3 element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))

Error Handling

new Error() without cause option

ast-grep needs review 2

ES2022 Error cause option chains errors for better debugging. When re-throwing or wrapping errors, include { cause: originalError }. Note: only useful when wrapping another error — standalone throws may not need it.

MDN Documentation →
Before (current)
throw new Error('Something failed')
After (modern)
throw new Error('Something failed', { cause: err })
FileLineCurrent codeSuggested fix
util/config.js 26:11 throw new Error('You have to implement the static method "NAME", for each component!')
tooltip.js 204:13 throw new Error('Please use show on visible elements')