|
| 1 | +import { DEV } from 'esm-env'; |
| 2 | +import { hydrating } from '../../hydration.js'; |
| 3 | +import { render_effect } from '../../reactivity/effects.js'; |
| 4 | +import { get_descriptors, object_assign } from '../../utils.js'; |
| 5 | +import { map_get, map_set } from '../../operations.js'; |
| 6 | +import { AttributeAliases, DelegatedEvents, namespace_svg } from '../../../../constants.js'; |
| 7 | +import { delegate } from './events.js'; |
| 8 | +import { autofocus } from './misc.js'; |
| 9 | + |
| 10 | +/** |
| 11 | + * The value/checked attribute in the template actually corresponds to the defaultValue property, so we need |
| 12 | + * to remove it upon hydration to avoid a bug when someone resets the form value. |
| 13 | + * @param {HTMLInputElement | HTMLSelectElement} dom |
| 14 | + * @returns {void} |
| 15 | + */ |
| 16 | +export function remove_input_attr_defaults(dom) { |
| 17 | + if (hydrating) { |
| 18 | + attr(dom, 'value', null); |
| 19 | + attr(dom, 'checked', null); |
| 20 | + } |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * @param {Element} dom |
| 25 | + * @param {string} attribute |
| 26 | + * @param {() => string} value |
| 27 | + */ |
| 28 | +export function attr_effect(dom, attribute, value) { |
| 29 | + render_effect(() => { |
| 30 | + attr(dom, attribute, value()); |
| 31 | + }); |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * @param {Element} dom |
| 36 | + * @param {string} attribute |
| 37 | + * @param {string | null} value |
| 38 | + */ |
| 39 | +export function attr(dom, attribute, value) { |
| 40 | + value = value == null ? null : value + ''; |
| 41 | + |
| 42 | + if (DEV) { |
| 43 | + check_src_in_dev_hydration(dom, attribute, value); |
| 44 | + } |
| 45 | + |
| 46 | + if ( |
| 47 | + !hydrating || |
| 48 | + (dom.getAttribute(attribute) !== value && |
| 49 | + // If we reset those, they would result in another network request, which we want to avoid. |
| 50 | + // We assume they are the same between client and server as checking if they are equal is expensive |
| 51 | + // (we can't just compare the strings as they can be different between client and server but result in the |
| 52 | + // same url, so we would need to create hidden anchor elements to compare them) |
| 53 | + attribute !== 'src' && |
| 54 | + attribute !== 'href' && |
| 55 | + attribute !== 'srcset') |
| 56 | + ) { |
| 57 | + if (value === null) { |
| 58 | + dom.removeAttribute(attribute); |
| 59 | + } else { |
| 60 | + dom.setAttribute(attribute, value); |
| 61 | + } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * @param {Element} dom |
| 67 | + * @param {string} attribute |
| 68 | + * @param {() => string} value |
| 69 | + */ |
| 70 | +export function xlink_attr_effect(dom, attribute, value) { |
| 71 | + render_effect(() => { |
| 72 | + xlink_attr(dom, attribute, value()); |
| 73 | + }); |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * @param {Element} dom |
| 78 | + * @param {string} attribute |
| 79 | + * @param {string} value |
| 80 | + */ |
| 81 | +export function xlink_attr(dom, attribute, value) { |
| 82 | + dom.setAttributeNS('http://www.w3.org/1999/xlink', attribute, value); |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * @param {any} node |
| 87 | + * @param {string} prop |
| 88 | + * @param {() => any} value |
| 89 | + */ |
| 90 | +export function set_custom_element_data_effect(node, prop, value) { |
| 91 | + render_effect(() => { |
| 92 | + set_custom_element_data(node, prop, value()); |
| 93 | + }); |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * @param {any} node |
| 98 | + * @param {string} prop |
| 99 | + * @param {any} value |
| 100 | + */ |
| 101 | +export function set_custom_element_data(node, prop, value) { |
| 102 | + if (prop in node) { |
| 103 | + node[prop] = typeof node[prop] === 'boolean' && value === '' ? true : value; |
| 104 | + } else { |
| 105 | + attr(node, prop, value); |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Like `spread_attributes` but self-contained |
| 111 | + * @param {Element & ElementCSSInlineStyle} dom |
| 112 | + * @param {() => Record<string, unknown>[]} attrs |
| 113 | + * @param {boolean} lowercase_attributes |
| 114 | + * @param {string} css_hash |
| 115 | + */ |
| 116 | +export function spread_attributes_effect(dom, attrs, lowercase_attributes, css_hash) { |
| 117 | + /** @type {Record<string, any> | undefined} */ |
| 118 | + var current; |
| 119 | + |
| 120 | + render_effect(() => { |
| 121 | + current = spread_attributes(dom, current, attrs(), lowercase_attributes, css_hash); |
| 122 | + }); |
| 123 | +} |
| 124 | + |
| 125 | +/** |
| 126 | + * Spreads attributes onto a DOM element, taking into account the currently set attributes |
| 127 | + * @param {Element & ElementCSSInlineStyle} dom |
| 128 | + * @param {Record<string, unknown> | undefined} prev |
| 129 | + * @param {Record<string, unknown>[]} attrs |
| 130 | + * @param {boolean} lowercase_attributes |
| 131 | + * @param {string} css_hash |
| 132 | + * @returns {Record<string, unknown>} |
| 133 | + */ |
| 134 | +export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_hash) { |
| 135 | + var next = object_assign({}, ...attrs); |
| 136 | + var has_hash = css_hash.length !== 0; |
| 137 | + |
| 138 | + for (var key in prev) { |
| 139 | + if (!(key in next)) { |
| 140 | + next[key] = null; |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + if (has_hash && !next.class) { |
| 145 | + next.class = ''; |
| 146 | + } |
| 147 | + |
| 148 | + var setters = map_get(setters_cache, dom.nodeName); |
| 149 | + if (!setters) map_set(setters_cache, dom.nodeName, (setters = get_setters(dom))); |
| 150 | + |
| 151 | + for (key in next) { |
| 152 | + var value = next[key]; |
| 153 | + if (value === prev?.[key]) continue; |
| 154 | + |
| 155 | + var prefix = key[0] + key[1]; // this is faster than key.slice(0, 2) |
| 156 | + if (prefix === '$$') continue; |
| 157 | + |
| 158 | + if (prefix === 'on') { |
| 159 | + /** @type {{ capture?: true }} */ |
| 160 | + var opts = {}; |
| 161 | + var event_name = key.slice(2); |
| 162 | + var delegated = DelegatedEvents.includes(event_name); |
| 163 | + |
| 164 | + if ( |
| 165 | + event_name.endsWith('capture') && |
| 166 | + event_name !== 'ongotpointercapture' && |
| 167 | + event_name !== 'onlostpointercapture' |
| 168 | + ) { |
| 169 | + event_name = event_name.slice(0, -7); |
| 170 | + opts.capture = true; |
| 171 | + } |
| 172 | + |
| 173 | + if (!delegated && prev?.[key]) { |
| 174 | + dom.removeEventListener(event_name, /** @type {any} */ (prev[key]), opts); |
| 175 | + } |
| 176 | + |
| 177 | + if (value != null) { |
| 178 | + if (!delegated) { |
| 179 | + dom.addEventListener(event_name, value, opts); |
| 180 | + } else { |
| 181 | + // @ts-ignore |
| 182 | + dom[`__${event_name}`] = value; |
| 183 | + delegate([event_name]); |
| 184 | + } |
| 185 | + } |
| 186 | + } else if (value == null) { |
| 187 | + dom.removeAttribute(key); |
| 188 | + } else if (key === 'style') { |
| 189 | + dom.style.cssText = value + ''; |
| 190 | + } else if (key === 'autofocus') { |
| 191 | + autofocus(/** @type {HTMLElement} */ (dom), Boolean(value)); |
| 192 | + } else if (key === '__value' || key === 'value') { |
| 193 | + // @ts-ignore |
| 194 | + dom.value = dom[key] = dom.__value = value; |
| 195 | + } else { |
| 196 | + var name = key; |
| 197 | + if (lowercase_attributes) { |
| 198 | + name = name.toLowerCase(); |
| 199 | + name = AttributeAliases[name] || name; |
| 200 | + } |
| 201 | + |
| 202 | + if (setters.includes(name)) { |
| 203 | + if (DEV) { |
| 204 | + check_src_in_dev_hydration(dom, name, value); |
| 205 | + } |
| 206 | + |
| 207 | + if ( |
| 208 | + !hydrating || |
| 209 | + // @ts-ignore see attr method for an explanation of src/srcset |
| 210 | + (dom[name] !== value && name !== 'src' && name !== 'href' && name !== 'srcset') |
| 211 | + ) { |
| 212 | + // @ts-ignore |
| 213 | + dom[name] = value; |
| 214 | + } |
| 215 | + } else if (typeof value !== 'function') { |
| 216 | + if (has_hash && name === 'class') { |
| 217 | + if (value) value += ' '; |
| 218 | + value += css_hash; |
| 219 | + } |
| 220 | + |
| 221 | + attr(dom, name, value); |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + return next; |
| 227 | +} |
| 228 | + |
| 229 | +/** |
| 230 | + * @param {Element} node |
| 231 | + * @param {() => Record<string, unknown>[]} attrs |
| 232 | + * @param {string} css_hash |
| 233 | + */ |
| 234 | +export function spread_dynamic_element_attributes_effect(node, attrs, css_hash) { |
| 235 | + /** @type {Record<string, any> | undefined} */ |
| 236 | + var current; |
| 237 | + |
| 238 | + render_effect(() => { |
| 239 | + current = spread_dynamic_element_attributes(node, current, attrs(), css_hash); |
| 240 | + }); |
| 241 | +} |
| 242 | + |
| 243 | +/** |
| 244 | + * @param {Element} node |
| 245 | + * @param {Record<string, unknown> | undefined} prev |
| 246 | + * @param {Record<string, unknown>[]} attrs |
| 247 | + * @param {string} css_hash |
| 248 | + */ |
| 249 | +export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) { |
| 250 | + if (node.tagName.includes('-')) { |
| 251 | + var next = object_assign({}, ...attrs); |
| 252 | + |
| 253 | + for (var key in prev) { |
| 254 | + if (!(key in next)) { |
| 255 | + next[key] = null; |
| 256 | + } |
| 257 | + } |
| 258 | + |
| 259 | + for (key in next) { |
| 260 | + set_custom_element_data(node, key, next[key]); |
| 261 | + } |
| 262 | + |
| 263 | + return next; |
| 264 | + } else { |
| 265 | + return spread_attributes( |
| 266 | + /** @type {Element & ElementCSSInlineStyle} */ (node), |
| 267 | + prev, |
| 268 | + attrs, |
| 269 | + node.namespaceURI !== namespace_svg, |
| 270 | + css_hash |
| 271 | + ); |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +/** |
| 276 | + * List of attributes that should always be set through the attr method, |
| 277 | + * because updating them through the property setter doesn't work reliably. |
| 278 | + * In the example of `width`/`height`, the problem is that the setter only |
| 279 | + * accepts numeric values, but the attribute can also be set to a string like `50%`. |
| 280 | + * If this list becomes too big, rethink this approach. |
| 281 | + */ |
| 282 | +var always_set_through_set_attribute = ['width', 'height']; |
| 283 | + |
| 284 | +/** @type {Map<string, string[]>} */ |
| 285 | +var setters_cache = new Map(); |
| 286 | + |
| 287 | +/** @param {Element} element */ |
| 288 | +function get_setters(element) { |
| 289 | + /** @type {string[]} */ |
| 290 | + var setters = []; |
| 291 | + |
| 292 | + // @ts-expect-error |
| 293 | + var descriptors = get_descriptors(element.__proto__); |
| 294 | + |
| 295 | + for (var key in descriptors) { |
| 296 | + if (descriptors[key].set && !always_set_through_set_attribute.includes(key)) { |
| 297 | + setters.push(key); |
| 298 | + } |
| 299 | + } |
| 300 | + |
| 301 | + return setters; |
| 302 | +} |
| 303 | + |
| 304 | +/** |
| 305 | + * @param {any} dom |
| 306 | + * @param {string} attribute |
| 307 | + * @param {string | null} value |
| 308 | + */ |
| 309 | +function check_src_in_dev_hydration(dom, attribute, value) { |
| 310 | + if (!hydrating) return; |
| 311 | + if (attribute !== 'src' && attribute !== 'href' && attribute !== 'srcset') return; |
| 312 | + |
| 313 | + if (attribute === 'srcset' && srcset_url_equal(dom, value)) return; |
| 314 | + if (src_url_equal(dom.getAttribute(attribute) ?? '', value ?? '')) return; |
| 315 | + |
| 316 | + // eslint-disable-next-line no-console |
| 317 | + console.error( |
| 318 | + `Detected a ${attribute} attribute value change during hydration. This will not be repaired during hydration, ` + |
| 319 | + `the ${attribute} value that came from the server will be used. Related element:`, |
| 320 | + dom, |
| 321 | + ' Differing value:', |
| 322 | + value |
| 323 | + ); |
| 324 | +} |
| 325 | + |
| 326 | +/** |
| 327 | + * @param {string} element_src |
| 328 | + * @param {string} url |
| 329 | + * @returns {boolean} |
| 330 | + */ |
| 331 | +function src_url_equal(element_src, url) { |
| 332 | + if (element_src === url) return true; |
| 333 | + return new URL(element_src, document.baseURI).href === new URL(url, document.baseURI).href; |
| 334 | +} |
| 335 | + |
| 336 | +/** @param {string} srcset */ |
| 337 | +function split_srcset(srcset) { |
| 338 | + return srcset.split(',').map((src) => src.trim().split(' ').filter(Boolean)); |
| 339 | +} |
| 340 | + |
| 341 | +/** |
| 342 | + * @param {HTMLSourceElement | HTMLImageElement} element |
| 343 | + * @param {string | undefined | null} srcset |
| 344 | + * @returns {boolean} |
| 345 | + */ |
| 346 | +export function srcset_url_equal(element, srcset) { |
| 347 | + var element_urls = split_srcset(element.srcset); |
| 348 | + var urls = split_srcset(srcset ?? ''); |
| 349 | + |
| 350 | + return ( |
| 351 | + urls.length === element_urls.length && |
| 352 | + urls.every( |
| 353 | + ([url, width], i) => |
| 354 | + width === element_urls[i][1] && |
| 355 | + // We need to test both ways because Vite will create an a full URL with |
| 356 | + // `new URL(asset, import.meta.url).href` for the client when `base: './'`, and the |
| 357 | + // relative URLs inside srcset are not automatically resolved to absolute URLs by |
| 358 | + // browsers (in contrast to img.src). This means both SSR and DOM code could |
| 359 | + // contain relative or absolute URLs. |
| 360 | + (src_url_equal(element_urls[i][0], url) || src_url_equal(url, element_urls[i][0])) |
| 361 | + ) |
| 362 | + ); |
| 363 | +} |
0 commit comments