Structural code patterns found via ast-grep (complements ESLint report)
These are not errors — they are opportunities for modernization that require human review
ast-grep
needs review
3
In modern JS, x != null covers both undefined and null. Or use optional chaining ?. / nullish coalescing ?? for safe access.
typeof x !== 'undefined'x !== undefined
— or —
x?.prop ?? fallback| File | Line | Current code | Suggested 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) { |
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).
for (const key of Object.keys(obj)) { ... }
Object.keys(obj).includes(name)for (const [key, val] of Object.entries(obj)) { ... }
Object.hasOwn(obj, name)| File | Line | Current code | Suggested 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)) { |
— |
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.
if (!Object.keys(obj).length) { ... }// Acceptable pattern, but allocates an array
// Consider extracting to a utility for hot paths| File | Line | Current code | Suggested fix |
|---|---|---|---|
| dom/event-handler.js | 233:12 | if (!Object.keys(storeElementEvent).length) { |
— |
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.
Object.assign(el.style, { left: x, top: y })el.style.left = `${x}px`
el.style.top = `${y}px`
— or CSS class toggle —
el.classList.add('positioned')| File | Line | Current code | Suggested 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'
}) |
— |
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.
el.className = 'my-class'el.classList.add('my-class')
— or for full set —
el.classList.value = 'my-class'| File | Line | Current code | Suggested 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 |
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.
const el = document.createElement('div')
el.className = 'foo'
el.innerHTML = '...'const tpl = `<div class="foo">...</div>`
container.insertAdjacentHTML('beforeend', tpl)| File | Line | Current code | Suggested 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') |
— |
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.
el.innerHTML = userContentel.textContent = text // for plain text
el.replaceChildren(...nodes) // for DOM nodes| File | Line | Current code | Suggested 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 |
— |
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.
element.parentNodeelement.parentElement // guaranteed Element|null
element.closest('selector') // ancestor lookup| File | Line | Current code | Suggested 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)) |
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+.
el.setAttribute('aria-expanded', 'true')
el.removeAttribute('aria-current')el.ariaExpanded = 'true'
el.ariaCurrent = null| File | Line | Current code | Suggested 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' |
ast-grep
needs review
3
Removing ARIA attributes can be done via IDL property assignment. Setting the property to null removes the underlying attribute.
el.removeAttribute('aria-current')el.ariaCurrent = null| File | Line | Current code | Suggested 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') |
— |
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.
el.tagName === 'A'
/input|textarea/i.test(el.tagName)el.matches('a')
el.matches('input, textarea')| File | Line | Current code | Suggested 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') |
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.
el.addEventListener('click', handler)
// later...
el.removeEventListener('click', handler)const ac = new AbortController()
el.addEventListener('click', handler, { signal: ac.signal })
// later...
ac.abort() // removes all listeners| File | Line | Current code | Suggested 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)) |
— |
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.
throw new Error('Something failed')throw new Error('Something failed', { cause: err })| File | Line | Current code | Suggested 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') |
— |