import { attributeName } from '../core/attributes'
const SVG_NS = 'http://www.w3.org/2000/svg'
const BOOLEAN_ATTRS = new Set([
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'defer',
'disabled',
'formnovalidate',
'hidden',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'selected',
])
const IDL_ATTRS = new Set([
'value',
'checked',
'selected',
'disabled',
'multiple',
'muted',
'readonly',
'contentEditable',
'spellcheck',
'draggable',
])
export function createHostNode(type: string, isSvg: boolean): Element {
if (isSvg || type === 'svg') {
return document.createElementNS(SVG_NS, type)
}
return document.createElement(type)
}
export function isSvgElement(el: Element): boolean {
return (el as any).ownerSVGElement !== undefined || el.tagName === 'svg'
}
export function setProp(
el: Element,
name: string,
next: any,
prev: any,
isSvg: boolean,
): void {
if (name === 'children' || name === 'key' || name === 'ref') return
const attr = attributeName(name, isSvg)
// defaultValue / defaultChecked are IDL-property-only — they seed the
// initial value/checked of a form control on first mount and must NOT be
// emitted as HTML attributes (which would leak as `defaultValue="..."`).
// Setting `el.defaultValue` on a fresh control also sets the initial
// `el.value`, which is what consumers expect. Skip on hydrated DOM so we
// don't clobber a live user-typed value.
if (name === 'defaultValue' || name === 'defaultChecked') {
if (prev === undefined && next != null) {
try {
;(el as any)[name] = next
} catch {}
}
return
}
if (name === 'className') {
if (isSvg) {
if (next == null) el.removeAttribute('class')
else el.setAttribute('class', '' + next)
} else {
;(el as HTMLElement).className = next == null ? '' : '' + next
}
return
}
if (name === 'class') {
if (next == null) el.removeAttribute('class')
else el.setAttribute('class', '' + next)
return
}
if (name === 'style') {
setStyle(el as HTMLElement, next, prev)
return
}
if (name === 'dangerouslySetInnerHTML') {
const nextHtml = next?.__html ?? ''
const prevHtml = prev?.__html ?? ''
if (nextHtml !== prevHtml) (el as HTMLElement).innerHTML = nextHtml
return
}
if (name[0] === 'o' && name[1] === 'n') {
setEventHandler(el, name, next, prev)
return
}
if (name === 'htmlFor') {
if (next == null) el.removeAttribute('for')
else el.setAttribute('for', '' + next)
return
}
if (!isSvg && IDL_ATTRS.has(name) && name in el) {
try {
;(el as any)[name] = next == null ? '' : next
return
} catch {}
}
if (BOOLEAN_ATTRS.has(name.toLowerCase())) {
if (next) el.setAttribute(attr, '')
else el.removeAttribute(attr)
return
}
// aria-* and data-* attributes stringify booleans to `"true"`/`"false"`
// rather than using the HTML boolean-attribute presence/absence semantics.
// This matches React and the ARIA spec (aria-hidden="false" is meaningful).
if (name.length > 5 && (name.charCodeAt(0) === 97 /* a */ || name.charCodeAt(0) === 100 /* d */)) {
if (name.startsWith('aria-') || name.startsWith('data-')) {
if (next == null) {
el.removeAttribute(attr)
} else {
el.setAttribute(attr, '' + next)
}
return
}
}
if (next == null || next === false) {
el.removeAttribute(attr)
} else if (next === true) {
el.setAttribute(attr, '')
} else {
el.setAttribute(attr, '' + next)
}
}
function setStyle(el: HTMLElement, next: any, prev: any): void {
const style = el.style
if (typeof next === 'string') {
style.cssText = next
return
}
if (typeof prev === 'string') style.cssText = ''
if (prev && typeof prev === 'object') {
for (const k in prev) {
if (!next || !(k in next)) setStyleProperty(style, k, '')
}
}
if (next && typeof next === 'object') {
for (const k in next) {
if (!prev || prev[k] !== next[k]) setStyleProperty(style, k, next[k])
}
}
}
function setStyleProperty(style: CSSStyleDeclaration, key: string, value: any): void {
if (key[0] === '-') {
style.setProperty(key, value == null ? '' : '' + value)
} else if (value == null || value === '') {
;(style as any)[key] = ''
} else if (typeof value === 'number' && !UNITLESS_STYLE.has(key)) {
;(style as any)[key] = value + 'px'
} else {
;(style as any)[key] = '' + value
}
}
const UNITLESS_STYLE = new Set([
'animationIterationCount',
'borderImageOutset',
'borderImageSlice',
'borderImageWidth',
'boxFlex',
'boxFlexGroup',
'boxOrdinalGroup',
'columnCount',
'columns',
'flex',
'flexGrow',
'flexPositive',
'flexShrink',
'flexNegative',
'flexOrder',
'gridArea',
'gridRow',
'gridRowEnd',
'gridRowSpan',
'gridRowStart',
'gridColumn',
'gridColumnEnd',
'gridColumnSpan',
'gridColumnStart',
'fontWeight',
'lineClamp',
'lineHeight',
'opacity',
'order',
'orphans',
'tabSize',
'widows',
'zIndex',
'zoom',
'fillOpacity',
'floodOpacity',
'stopOpacity',
'strokeDasharray',
'strokeDashoffset',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
])
export interface SyntheticEvent extends Event {
nativeEvent: Event
isDefaultPrevented(): boolean
isPropagationStopped(): boolean
persist(): void
currentTarget: EventTarget & Element
}
function setEventHandler(el: Element, name: string, next: any, prev: any): void {
// Only function handlers are accepted. Reject strings/objects/etc — the
// legacy `onclick="..."` HTML attribute (string handler) is a known XSS
// vector if a parent spreads untrusted props onto a host element. React
// also ignores non-function handlers.
if (next != null && typeof next != 'function') next = null
const capture = name.endsWith('Capture')
const reactEventName = name.slice(2, capture ? -7 : undefined)
const eventName = domEventFor(reactEventName, el)
// Key by the React prop name (not DOM event name) so `onChange` and
// `onInput` on the same text input — which both dispatch from the native
// `input` event — can coexist without clobbering each other's handlers.
const handlers = ((el as any).__handlers ||= Object.create(null))
const key = name
const existing = handlers[key]
if (existing && !next) {
el.removeEventListener(existing.event, existing.listener, existing.capture)
handlers[key] = null
return
}
if (!existing && next) {
const entry = {
current: next as Function,
listener: null as any,
event: eventName,
capture,
}
entry.listener = (e: Event) => {
// React hands handlers a SyntheticEvent that carries `.nativeEvent`.
// Many libraries check `event.nativeEvent.isComposing` (react-instantsearch)
// or `event.nativeEvent.shiftKey` (UI kits) — without the alias they throw
// on `undefined.foo`. Aliasing the native event to itself is the cheapest
// way to satisfy the shape without building a full synthetic layer.
if ((e as any).nativeEvent === undefined) (e as any).nativeEvent = e
entry.current(e)
}
handlers[key] = entry
el.addEventListener(eventName, entry.listener, capture)
return
}
if (existing) {
// Re-bind if the effective DOM event changed (e.g. whose `type`
// flipped from "text" to "checkbox" — onChange should now follow `change`
// instead of `input`). This is rare but keeps semantics correct.
if (existing.event !== eventName || existing.capture !== capture) {
el.removeEventListener(existing.event, existing.listener, existing.capture)
existing.event = eventName
existing.capture = capture
el.addEventListener(eventName, existing.listener, capture)
}
existing.current = next
}
}
function domEventFor(reactEventName: string, el: Element): string {
const lower = reactEventName.toLowerCase()
// React's `onChange` on text-like and