|
| 1 | +import { invariant } from './invariant.ts' |
| 2 | +import { processStyle, createStyleManager, normalizeCssValue } from './style/index.ts' |
| 3 | +import type { ElementProps } from './jsx.ts' |
| 4 | + |
| 5 | +const SVG_NS = 'http://www.w3.org/2000/svg' |
| 6 | +const XLINK_NS = 'http://www.w3.org/1999/xlink' |
| 7 | +const XML_NS = 'http://www.w3.org/XML/1998/namespace' |
| 8 | + |
| 9 | +// global so all roots share it |
| 10 | +let styleCache = new Map<string, { selector: string; css: string }>() |
| 11 | +let styleManager = |
| 12 | + typeof window !== 'undefined' |
| 13 | + ? createStyleManager() |
| 14 | + : (null as unknown as ReturnType<typeof createStyleManager>) |
| 15 | + |
| 16 | +export function cleanupCssProps(props: ElementProps | undefined) { |
| 17 | + if (!props?.css) return |
| 18 | + let { selector } = processStyle(props.css, styleCache) |
| 19 | + if (selector) { |
| 20 | + styleManager.remove(selector) |
| 21 | + } |
| 22 | +} |
| 23 | + |
| 24 | +function diffCssProp(curr: ElementProps, next: ElementProps, dom: Element) { |
| 25 | + let prevSelector = curr.css ? processStyle(curr.css, styleCache).selector : '' |
| 26 | + let { selector: nextSelector, css } = next.css |
| 27 | + ? processStyle(next.css, styleCache) |
| 28 | + : { selector: '', css: '' } |
| 29 | + |
| 30 | + if (prevSelector === nextSelector) return |
| 31 | + |
| 32 | + // Remove old CSS |
| 33 | + if (prevSelector) { |
| 34 | + dom.removeAttribute('data-css') |
| 35 | + styleManager.remove(prevSelector) |
| 36 | + } |
| 37 | + |
| 38 | + // Add new CSS |
| 39 | + if (css && nextSelector) { |
| 40 | + dom.setAttribute('data-css', nextSelector) |
| 41 | + styleManager.insert(nextSelector, css) |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +// Preact excludes certain attributes from the property path due to browser quirks |
| 46 | +const ATTRIBUTE_FALLBACK_NAMES = new Set([ |
| 47 | + 'width', |
| 48 | + 'height', |
| 49 | + 'href', |
| 50 | + 'list', |
| 51 | + 'form', |
| 52 | + 'tabIndex', |
| 53 | + 'download', |
| 54 | + 'rowSpan', |
| 55 | + 'colSpan', |
| 56 | + 'role', |
| 57 | + 'popover', |
| 58 | +]) |
| 59 | + |
| 60 | +// Determine if we should use the property path for a given name. |
| 61 | +// Also acts as a type guard to allow bracket assignment without casts. |
| 62 | +function canUseProperty( |
| 63 | + dom: Element, |
| 64 | + name: string, |
| 65 | + isSvg: boolean, |
| 66 | +): dom is Element & Record<string, unknown> { |
| 67 | + if (isSvg) return false |
| 68 | + if (ATTRIBUTE_FALLBACK_NAMES.has(name)) return false |
| 69 | + return name in dom |
| 70 | +} |
| 71 | + |
| 72 | +function isFrameworkProp(name: string): boolean { |
| 73 | + return ( |
| 74 | + name === 'children' || |
| 75 | + name === 'key' || |
| 76 | + name === 'on' || |
| 77 | + name === 'css' || |
| 78 | + name === 'setup' || |
| 79 | + name === 'connect' || |
| 80 | + name === 'animate' || |
| 81 | + name === 'innerHTML' |
| 82 | + ) |
| 83 | +} |
| 84 | + |
| 85 | +// TODO: would rather actually diff el.style object directly instead of writing |
| 86 | +// to the style attribute |
| 87 | +function serializeStyleObject(style: Record<string, unknown>): string { |
| 88 | + let parts: string[] = [] |
| 89 | + for (let [key, value] of Object.entries(style)) { |
| 90 | + if (value == null) continue |
| 91 | + if (typeof value === 'boolean') continue |
| 92 | + if (typeof value === 'number' && !Number.isFinite(value)) continue |
| 93 | + |
| 94 | + let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) |
| 95 | + |
| 96 | + let cssValue = Array.isArray(value) |
| 97 | + ? (value as unknown[]).join(', ') |
| 98 | + : normalizeCssValue(key, value) |
| 99 | + |
| 100 | + parts.push(`${cssKey}: ${cssValue};`) |
| 101 | + } |
| 102 | + return parts.join(' ') |
| 103 | +} |
| 104 | + |
| 105 | +function normalizePropName(name: string, isSvg: boolean): { ns?: string; attr: string } { |
| 106 | + // aria-/data- pass through |
| 107 | + if (name.startsWith('aria-') || name.startsWith('data-')) return { attr: name } |
| 108 | + |
| 109 | + // DOM property -> HTML mappings |
| 110 | + if (!isSvg) { |
| 111 | + if (name === 'className') return { attr: 'class' } |
| 112 | + if (name === 'htmlFor') return { attr: 'for' } |
| 113 | + if (name === 'tabIndex') return { attr: 'tabindex' } |
| 114 | + if (name === 'acceptCharset') return { attr: 'accept-charset' } |
| 115 | + if (name === 'httpEquiv') return { attr: 'http-equiv' } |
| 116 | + return { attr: name.toLowerCase() } |
| 117 | + } |
| 118 | + |
| 119 | + // SVG namespaced specials |
| 120 | + if (name === 'xlinkHref') return { ns: XLINK_NS, attr: 'xlink:href' } |
| 121 | + if (name === 'xmlLang') return { ns: XML_NS, attr: 'xml:lang' } |
| 122 | + if (name === 'xmlSpace') return { ns: XML_NS, attr: 'xml:space' } |
| 123 | + |
| 124 | + // SVG preserved-case exceptions |
| 125 | + if ( |
| 126 | + name === 'viewBox' || |
| 127 | + name === 'preserveAspectRatio' || |
| 128 | + name === 'gradientUnits' || |
| 129 | + name === 'gradientTransform' || |
| 130 | + name === 'patternUnits' || |
| 131 | + name === 'patternTransform' || |
| 132 | + name === 'clipPathUnits' || |
| 133 | + name === 'maskUnits' || |
| 134 | + name === 'maskContentUnits' |
| 135 | + ) { |
| 136 | + return { attr: name } |
| 137 | + } |
| 138 | + |
| 139 | + // General SVG: kebab-case |
| 140 | + return { attr: camelToKebab(name) } |
| 141 | +} |
| 142 | + |
| 143 | +function camelToKebab(input: string): string { |
| 144 | + return input |
| 145 | + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') |
| 146 | + .replace(/_/g, '-') |
| 147 | + .toLowerCase() |
| 148 | +} |
| 149 | + |
| 150 | +export function diffHostProps(curr: ElementProps, next: ElementProps, dom: Element) { |
| 151 | + let isSvg = dom.namespaceURI === SVG_NS |
| 152 | + |
| 153 | + if (next.css || curr.css) { |
| 154 | + diffCssProp(curr, next, dom) |
| 155 | + } |
| 156 | + |
| 157 | + // Removals |
| 158 | + for (let name in curr) { |
| 159 | + if (isFrameworkProp(name)) continue |
| 160 | + if (!(name in next) || next[name] == null) { |
| 161 | + // Prefer property clearing when applicable (align with Preact) |
| 162 | + if (canUseProperty(dom, name, isSvg)) { |
| 163 | + try { |
| 164 | + dom[name] = '' |
| 165 | + continue |
| 166 | + } catch {} |
| 167 | + } |
| 168 | + |
| 169 | + let { ns, attr } = normalizePropName(name, isSvg) |
| 170 | + if (ns) dom.removeAttributeNS(ns, attr) |
| 171 | + else dom.removeAttribute(attr) |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + // Additions/updates |
| 176 | + for (let name in next) { |
| 177 | + if (isFrameworkProp(name)) continue |
| 178 | + let nextValue = next[name] |
| 179 | + if (nextValue == null) continue |
| 180 | + let prevValue = curr[name] |
| 181 | + if (prevValue !== nextValue) { |
| 182 | + let { ns, attr } = normalizePropName(name, isSvg) |
| 183 | + |
| 184 | + // Object style: serialize to attribute for now |
| 185 | + if ( |
| 186 | + attr === 'style' && |
| 187 | + typeof nextValue === 'object' && |
| 188 | + nextValue && |
| 189 | + !Array.isArray(nextValue) |
| 190 | + ) { |
| 191 | + dom.setAttribute('style', serializeStyleObject(nextValue)) |
| 192 | + continue |
| 193 | + } |
| 194 | + |
| 195 | + // Prefer property assignment when possible (HTML only, not SVG) |
| 196 | + if (canUseProperty(dom, name, isSvg)) { |
| 197 | + try { |
| 198 | + dom[name] = nextValue == null ? '' : nextValue |
| 199 | + continue |
| 200 | + } catch {} |
| 201 | + } |
| 202 | + |
| 203 | + // Attribute path |
| 204 | + if (typeof nextValue === 'function') { |
| 205 | + // Never serialize functions as attribute values |
| 206 | + continue |
| 207 | + } |
| 208 | + |
| 209 | + let isAriaOrData = name.startsWith('aria-') || name.startsWith('data-') |
| 210 | + if (nextValue != null && (nextValue !== false || isAriaOrData)) { |
| 211 | + // Special-case popover: true => presence only |
| 212 | + let attrValue = name === 'popover' && nextValue === true ? '' : String(nextValue) |
| 213 | + if (ns) dom.setAttributeNS(ns, attr, attrValue) |
| 214 | + else dom.setAttribute(attr, attrValue) |
| 215 | + } else { |
| 216 | + if (ns) dom.removeAttributeNS(ns, attr) |
| 217 | + else dom.removeAttribute(attr) |
| 218 | + } |
| 219 | + } |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +/** |
| 224 | + * Reset the global style state. For testing only - not exported from index.ts. |
| 225 | + */ |
| 226 | +export function resetStyleState() { |
| 227 | + styleCache.clear() |
| 228 | + invariant( |
| 229 | + typeof window !== 'undefined', |
| 230 | + 'resetStyleState() is only available in a browser environment', |
| 231 | + ) |
| 232 | + styleManager.dispose() |
| 233 | + styleManager = createStyleManager() |
| 234 | +} |
0 commit comments