From f13fee3a6dd86114be27d6bcae6d632a4f7cf6bd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 12 Mar 2024 14:25:43 -0400 Subject: [PATCH 1/2] move element code --- .../client/dom/elements/attributes.js | 363 ++++++++++ .../src/internal/client/dom/elements/class.js | 78 +++ .../internal/client/dom/elements/events.js | 154 +++++ .../src/internal/client/dom/elements/misc.js | 37 + .../src/internal/client/dom/elements/style.js | 33 + packages/svelte/src/internal/client/render.js | 650 +----------------- packages/svelte/src/internal/index.js | 5 + 7 files changed, 676 insertions(+), 644 deletions(-) create mode 100644 packages/svelte/src/internal/client/dom/elements/attributes.js create mode 100644 packages/svelte/src/internal/client/dom/elements/class.js create mode 100644 packages/svelte/src/internal/client/dom/elements/events.js create mode 100644 packages/svelte/src/internal/client/dom/elements/misc.js create mode 100644 packages/svelte/src/internal/client/dom/elements/style.js diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js new file mode 100644 index 000000000000..94c126617e57 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -0,0 +1,363 @@ +import { DEV } from 'esm-env'; +import { hydrating } from '../../hydration.js'; +import { render_effect } from '../../reactivity/effects.js'; +import { get_descriptors, object_assign } from '../../utils.js'; +import { map_get, map_set } from '../../operations.js'; +import { AttributeAliases, DelegatedEvents, namespace_svg } from '../../../../constants.js'; +import { delegate } from './events.js'; +import { autofocus } from './misc.js'; + +/** + * The value/checked attribute in the template actually corresponds to the defaultValue property, so we need + * to remove it upon hydration to avoid a bug when someone resets the form value. + * @param {HTMLInputElement | HTMLSelectElement} dom + * @returns {void} + */ +export function remove_input_attr_defaults(dom) { + if (hydrating) { + attr(dom, 'value', null); + attr(dom, 'checked', null); + } +} + +/** + * @param {Element} dom + * @param {string} attribute + * @param {() => string} value + */ +export function attr_effect(dom, attribute, value) { + render_effect(() => { + attr(dom, attribute, value()); + }); +} + +/** + * @param {Element} dom + * @param {string} attribute + * @param {string | null} value + */ +export function attr(dom, attribute, value) { + value = value == null ? null : value + ''; + + if (DEV) { + check_src_in_dev_hydration(dom, attribute, value); + } + + if ( + !hydrating || + (dom.getAttribute(attribute) !== value && + // If we reset those, they would result in another network request, which we want to avoid. + // We assume they are the same between client and server as checking if they are equal is expensive + // (we can't just compare the strings as they can be different between client and server but result in the + // same url, so we would need to create hidden anchor elements to compare them) + attribute !== 'src' && + attribute !== 'href' && + attribute !== 'srcset') + ) { + if (value === null) { + dom.removeAttribute(attribute); + } else { + dom.setAttribute(attribute, value); + } + } +} + +/** + * @param {Element} dom + * @param {string} attribute + * @param {() => string} value + */ +export function xlink_attr_effect(dom, attribute, value) { + render_effect(() => { + xlink_attr(dom, attribute, value()); + }); +} + +/** + * @param {Element} dom + * @param {string} attribute + * @param {string} value + */ +export function xlink_attr(dom, attribute, value) { + dom.setAttributeNS('http://www.w3.org/1999/xlink', attribute, value); +} + +/** + * @param {any} node + * @param {string} prop + * @param {() => any} value + */ +export function set_custom_element_data_effect(node, prop, value) { + render_effect(() => { + set_custom_element_data(node, prop, value()); + }); +} + +/** + * @param {any} node + * @param {string} prop + * @param {any} value + */ +export function set_custom_element_data(node, prop, value) { + if (prop in node) { + node[prop] = typeof node[prop] === 'boolean' && value === '' ? true : value; + } else { + attr(node, prop, value); + } +} + +/** + * Like `spread_attributes` but self-contained + * @param {Element & ElementCSSInlineStyle} dom + * @param {() => Record[]} attrs + * @param {boolean} lowercase_attributes + * @param {string} css_hash + */ +export function spread_attributes_effect(dom, attrs, lowercase_attributes, css_hash) { + /** @type {Record | undefined} */ + var current; + + render_effect(() => { + current = spread_attributes(dom, current, attrs(), lowercase_attributes, css_hash); + }); +} + +/** + * Spreads attributes onto a DOM element, taking into account the currently set attributes + * @param {Element & ElementCSSInlineStyle} dom + * @param {Record | undefined} prev + * @param {Record[]} attrs + * @param {boolean} lowercase_attributes + * @param {string} css_hash + * @returns {Record} + */ +export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_hash) { + var next = object_assign({}, ...attrs); + var has_hash = css_hash.length !== 0; + + for (var key in prev) { + if (!(key in next)) { + next[key] = null; + } + } + + if (has_hash && !next.class) { + next.class = ''; + } + + var setters = map_get(setters_cache, dom.nodeName); + if (!setters) map_set(setters_cache, dom.nodeName, (setters = get_setters(dom))); + + for (var key in next) { + var value = next[key]; + if (value === prev?.[key]) continue; + + var prefix = key[0] + key[1]; // this is faster than key.slice(0, 2) + if (prefix === '$$') continue; + + if (prefix === 'on') { + /** @type {{ capture?: true }} */ + var opts = {}; + var event_name = key.slice(2); + var delegated = DelegatedEvents.includes(event_name); + + if ( + event_name.endsWith('capture') && + event_name !== 'ongotpointercapture' && + event_name !== 'onlostpointercapture' + ) { + event_name = event_name.slice(0, -7); + opts.capture = true; + } + + if (!delegated && prev?.[key]) { + dom.removeEventListener(event_name, /** @type {any} */ (prev[key]), opts); + } + + if (value != null) { + if (!delegated) { + dom.addEventListener(event_name, value, opts); + } else { + // @ts-ignore + dom[`__${event_name}`] = value; + delegate([event_name]); + } + } + } else if (value == null) { + dom.removeAttribute(key); + } else if (key === 'style') { + dom.style.cssText = value + ''; + } else if (key === 'autofocus') { + autofocus(/** @type {HTMLElement} */ (dom), Boolean(value)); + } else if (key === '__value' || key === 'value') { + // @ts-ignore + dom.value = dom[key] = dom.__value = value; + } else { + var name = key; + if (lowercase_attributes) { + name = name.toLowerCase(); + name = AttributeAliases[name] || name; + } + + if (setters.includes(name)) { + if (DEV) { + check_src_in_dev_hydration(dom, name, value); + } + + if ( + !hydrating || + // @ts-ignore see attr method for an explanation of src/srcset + (dom[name] !== value && name !== 'src' && name !== 'href' && name !== 'srcset') + ) { + // @ts-ignore + dom[name] = value; + } + } else if (typeof value !== 'function') { + if (has_hash && name === 'class') { + if (value) value += ' '; + value += css_hash; + } + + attr(dom, name, value); + } + } + } + + return next; +} + +/** + * @param {Element} node + * @param {() => Record[]} attrs + * @param {string} css_hash + */ +export function spread_dynamic_element_attributes_effect(node, attrs, css_hash) { + /** @type {Record | undefined} */ + var current; + + render_effect(() => { + current = spread_dynamic_element_attributes(node, current, attrs(), css_hash); + }); +} + +/** + * @param {Element} node + * @param {Record | undefined} prev + * @param {Record[]} attrs + * @param {string} css_hash + */ +export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) { + if (node.tagName.includes('-')) { + var next = object_assign({}, ...attrs); + + for (var key in prev) { + if (!(key in next)) { + next[key] = null; + } + } + + for (var key in next) { + set_custom_element_data(node, key, next[key]); + } + + return next; + } else { + return spread_attributes( + /** @type {Element & ElementCSSInlineStyle} */ (node), + prev, + attrs, + node.namespaceURI !== namespace_svg, + css_hash + ); + } +} + +/** + * List of attributes that should always be set through the attr method, + * because updating them through the property setter doesn't work reliably. + * In the example of `width`/`height`, the problem is that the setter only + * accepts numeric values, but the attribute can also be set to a string like `50%`. + * If this list becomes too big, rethink this approach. + */ +var always_set_through_set_attribute = ['width', 'height']; + +/** @type {Map} */ +var setters_cache = new Map(); + +/** @param {Element} element */ +function get_setters(element) { + /** @type {string[]} */ + var setters = []; + + // @ts-expect-error + var descriptors = get_descriptors(element.__proto__); + + for (var key in descriptors) { + if (descriptors[key].set && !always_set_through_set_attribute.includes(key)) { + setters.push(key); + } + } + + return setters; +} + +/** + * @param {any} dom + * @param {string} attribute + * @param {string | null} value + */ +function check_src_in_dev_hydration(dom, attribute, value) { + if (!hydrating) return; + if (attribute !== 'src' && attribute !== 'href' && attribute !== 'srcset') return; + + if (attribute === 'srcset' && srcset_url_equal(dom, value)) return; + if (src_url_equal(dom.getAttribute(attribute) ?? '', value ?? '')) return; + + // eslint-disable-next-line no-console + console.error( + `Detected a ${attribute} attribute value change during hydration. This will not be repaired during hydration, ` + + `the ${attribute} value that came from the server will be used. Related element:`, + dom, + ' Differing value:', + value + ); +} + +/** + * @param {string} element_src + * @param {string} url + * @returns {boolean} + */ +function src_url_equal(element_src, url) { + if (element_src === url) return true; + return new URL(element_src, document.baseURI).href === new URL(url, document.baseURI).href; +} + +/** @param {string} srcset */ +function split_srcset(srcset) { + return srcset.split(',').map((src) => src.trim().split(' ').filter(Boolean)); +} + +/** + * @param {HTMLSourceElement | HTMLImageElement} element + * @param {string | undefined | null} srcset + * @returns {boolean} + */ +export function srcset_url_equal(element, srcset) { + var element_urls = split_srcset(element.srcset); + var urls = split_srcset(srcset ?? ''); + + return ( + urls.length === element_urls.length && + urls.every( + ([url, width], i) => + width === element_urls[i][1] && + // We need to test both ways because Vite will create an a full URL with + // `new URL(asset, import.meta.url).href` for the client when `base: './'`, and the + // relative URLs inside srcset are not automatically resolved to absolute URLs by + // browsers (in contrast to img.src). This means both SSR and DOM code could + // contain relative or absolute URLs. + (src_url_equal(element_urls[i][0], url) || src_url_equal(url, element_urls[i][0])) + ) + ); +} diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js new file mode 100644 index 000000000000..e180ed522bb8 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -0,0 +1,78 @@ +import { hydrating } from '../../hydration.js'; +import { set_class_name } from '../../operations.js'; +import { render_effect } from '../../reactivity/effects.js'; + +/** + * @param {Element} dom + * @param {() => string} value + * @returns {void} + */ +export function class_name_effect(dom, value) { + render_effect(() => { + class_name(dom, value()); + }); +} + +/** + * @param {Element} dom + * @param {string} value + * @returns {void} + */ +export function class_name(dom, value) { + // @ts-expect-error need to add __className to patched prototype + var prev_class_name = dom.__className; + var next_class_name = to_class(value); + + if (hydrating && dom.className === next_class_name) { + // In case of hydration don't reset the class as it's already correct. + // @ts-expect-error need to add __className to patched prototype + dom.__className = next_class_name; + } else if ( + prev_class_name !== next_class_name || + (hydrating && dom.className !== next_class_name) + ) { + if (next_class_name === '') { + dom.removeAttribute('class'); + } else { + set_class_name(dom, next_class_name); + } + + // @ts-expect-error need to add __className to patched prototype + dom.__className = next_class_name; + } +} + +/** + * @template V + * @param {V} value + * @returns {string | V} + */ +export function to_class(value) { + return value == null ? '' : value; +} + +/** + * @param {Element} dom + * @param {string} class_name + * @param {boolean} value + * @returns {void} + */ +export function class_toggle(dom, class_name, value) { + if (value) { + dom.classList.add(class_name); + } else { + dom.classList.remove(class_name); + } +} + +/** + * @param {Element} dom + * @param {string} class_name + * @param {() => boolean} value + * @returns {void} + */ +export function class_toggle_effect(dom, class_name, value) { + render_effect(() => { + class_toggle(dom, class_name, value()); + }); +} diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js new file mode 100644 index 000000000000..bd5a77dffb46 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -0,0 +1,154 @@ +import { render_effect } from '../../reactivity/effects.js'; +import { all_registered_events, root_event_handles } from '../../render.js'; +import { define_property, is_array } from '../../utils.js'; + +/** + * @param {string} event_name + * @param {Element} dom + * @param {EventListener} handler + * @param {boolean} capture + * @param {boolean} [passive] + * @returns {void} + */ +export function event(event_name, dom, handler, capture, passive) { + var options = { capture, passive }; + + /** + * @this {EventTarget} + */ + function target_handler(/** @type {Event} */ event) { + handle_event_propagation(dom, event); + if (!event.cancelBubble) { + return handler.call(this, event); + } + } + + dom.addEventListener(event_name, target_handler, options); + + // @ts-ignore + if (dom === document.body || dom === window || dom === document) { + render_effect(() => { + return () => { + dom.removeEventListener(event_name, target_handler, options); + }; + }); + } +} + +/** + * @param {Array} events + * @returns {void} + */ +export function delegate(events) { + for (var i = 0; i < events.length; i++) { + all_registered_events.add(events[i]); + } + + for (var fn of root_event_handles) { + fn(events); + } +} + +/** + * @param {Node} handler_element + * @param {Event} event + * @returns {void} + */ +export function handle_event_propagation(handler_element, event) { + var owner_document = handler_element.ownerDocument; + var event_name = event.type; + var path = event.composedPath?.() || []; + var current_target = /** @type {null | Element} */ (path[0] || event.target); + + if (event.target !== current_target) { + define_property(event, 'target', { + configurable: true, + value: current_target + }); + } + + // composedPath contains list of nodes the event has propagated through. + // We check __root to skip all nodes below it in case this is a + // parent of the __root node, which indicates that there's nested + // mounted apps. In this case we don't want to trigger events multiple times. + var path_idx = 0; + + // @ts-expect-error is added below + var handled_at = event.__root; + + if (handled_at) { + var at_idx = path.indexOf(handled_at); + if ( + at_idx !== -1 && + (handler_element === document || handler_element === /** @type {any} */ (window)) + ) { + // This is the fallback document listener or a window listener, but the event was already handled + // -> ignore, but set handle_at to document/window so that we're resetting the event + // chain in case someone manually dispatches the same event object again. + // @ts-expect-error + event.__root = handler_element; + return; + } + + // We're deliberately not skipping if the index is higher, because + // someone could create an event programmatically and emit it multiple times, + // in which case we want to handle the whole propagation chain properly each time. + // (this will only be a false negative if the event is dispatched multiple times and + // the fallback document listener isn't reached in between, but that's super rare) + var handler_idx = path.indexOf(handler_element); + if (handler_idx === -1) { + // handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object) + // so guard against that, too, and assume that everything was handled at this point. + return; + } + + if (at_idx <= handler_idx) { + // +1 because at_idx is the element which was already handled, and there can only be one delegated event per element. + // Avoids on:click and onclick on the same event resulting in onclick being fired twice. + path_idx = at_idx + 1; + } + } + + current_target = /** @type {Element} */ (path[path_idx] || event.target); + + // Proxy currentTarget to correct target + define_property(event, 'currentTarget', { + configurable: true, + get() { + return current_target || owner_document; + } + }); + + while (current_target !== null) { + /** @type {null | Element} */ + var parent_element = + current_target.parentNode || /** @type {any} */ (current_target).host || null; + var internal_prop_name = '__' + event_name; + // @ts-ignore + var delegated = current_target[internal_prop_name]; + + if (delegated !== undefined && !(/** @type {any} */ (current_target).disabled)) { + if (is_array(delegated)) { + var [fn, ...data] = delegated; + fn.apply(current_target, [event, ...data]); + } else { + delegated.call(current_target, event); + } + } + + if ( + event.cancelBubble || + parent_element === handler_element || + current_target === handler_element + ) { + break; + } + + current_target = parent_element; + } + + // @ts-expect-error is used above + event.__root = handler_element; + // @ts-expect-error is used above + current_target = handler_element; +} diff --git a/packages/svelte/src/internal/client/dom/elements/misc.js b/packages/svelte/src/internal/client/dom/elements/misc.js new file mode 100644 index 000000000000..3a117dcd69e9 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/misc.js @@ -0,0 +1,37 @@ +import { hydrating } from '../../hydration.js'; +import { render_effect } from '../../reactivity/effects.js'; +import { current_block } from '../../runtime.js'; + +/** + * @param {HTMLElement} dom + * @param {boolean} value + * @returns {void} + */ +export function autofocus(dom, value) { + if (value) { + const body = document.body; + dom.autofocus = true; + render_effect( + () => { + if (document.activeElement === body) { + dom.focus(); + } + }, + current_block, + true, + false + ); + } +} + +/** + * The child of a textarea actually corresponds to the defaultValue property, so we need + * to remove it upon hydration to avoid a bug when someone resets the form value. + * @param {HTMLTextAreaElement} dom + * @returns {void} + */ +export function remove_textarea_child(dom) { + if (hydrating && dom.firstChild !== null) { + dom.textContent = ''; + } +} diff --git a/packages/svelte/src/internal/client/dom/elements/style.js b/packages/svelte/src/internal/client/dom/elements/style.js new file mode 100644 index 000000000000..0805b4bb1f84 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/elements/style.js @@ -0,0 +1,33 @@ +import { render_effect } from '../../reactivity/effects.js'; + +/** + * @param {HTMLElement} dom + * @param {string} key + * @param {string} value + * @param {boolean} [important] + */ +export function style(dom, key, value, important) { + const style = dom.style; + const prev_value = style.getPropertyValue(key); + if (value == null) { + if (prev_value !== '') { + style.removeProperty(key); + } + } else if (prev_value !== value) { + style.setProperty(key, value, important ? 'important' : ''); + } +} + +/** + * @param {HTMLElement} dom + * @param {string} key + * @param {() => string} value + * @param {boolean} [important] + * @returns {void} + */ +export function style_effect(dom, key, value, important) { + render_effect(() => { + const string = value(); + style(dom, key, string, important); + }); +} diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 2324b431aab7..f2704e9d2637 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -1,24 +1,10 @@ import { DEV } from 'esm-env'; -import { - append_child, - create_element, - empty, - init_operations, - map_get, - map_set, - set_class_name -} from './operations.js'; -import { - PassiveDelegatedEvents, - DelegatedEvents, - AttributeAliases, - namespace_svg -} from '../../constants.js'; +import { append_child, create_element, empty, init_operations } from './operations.js'; +import { PassiveDelegatedEvents } from '../../constants.js'; import { remove } from './reconciler.js'; import { untrack, flush_sync, - current_block, push, pop, current_component_context, @@ -32,87 +18,16 @@ import { hydrating, set_current_hydration_fragment } from './hydration.js'; -import { array_from, define_property, get_descriptors, is_array, object_assign } from './utils.js'; +import { array_from } from './utils.js'; import { bind_transition } from './transitions.js'; import { ROOT_BLOCK } from './constants.js'; +import { handle_event_propagation } from './dom/elements/events.js'; /** @type {Set} */ -const all_registered_events = new Set(); +export const all_registered_events = new Set(); /** @type {Set<(events: Array) => void>} */ -const root_event_handles = new Set(); - -/** - * @param {string} event_name - * @param {Element} dom - * @param {EventListener} handler - * @param {boolean} capture - * @param {boolean} [passive] - * @returns {void} - */ -export function event(event_name, dom, handler, capture, passive) { - const options = { - capture, - passive - }; - /** - * @this {EventTarget} - */ - function target_handler(/** @type {Event} */ event) { - handle_event_propagation(dom, event); - if (!event.cancelBubble) { - return handler.call(this, event); - } - } - dom.addEventListener(event_name, target_handler, options); - // @ts-ignore - if (dom === document.body || dom === window || dom === document) { - render_effect(() => { - return () => { - dom.removeEventListener(event_name, target_handler, options); - }; - }); - } -} - -/** - * @param {Element} dom - * @param {() => string} value - * @returns {void} - */ -export function class_name_effect(dom, value) { - render_effect(() => { - const string = value(); - class_name(dom, string); - }); -} - -/** - * @param {Element} dom - * @param {string} value - * @returns {void} - */ -export function class_name(dom, value) { - // @ts-expect-error need to add __className to patched prototype - const prev_class_name = dom.__className; - const next_class_name = to_class(value); - if (hydrating && dom.className === next_class_name) { - // In case of hydration don't reset the class as it's already correct. - // @ts-expect-error need to add __className to patched prototype - dom.__className = next_class_name; - } else if ( - prev_class_name !== next_class_name || - (hydrating && dom.className !== next_class_name) - ) { - if (next_class_name === '') { - dom.removeAttribute('class'); - } else { - set_class_name(dom, next_class_name); - } - // @ts-expect-error need to add __className to patched prototype - dom.__className = next_class_name; - } -} +export const root_event_handles = new Set(); /** * @param {Element} dom @@ -143,172 +58,6 @@ export function text(dom, value) { } } -/** - * @param {HTMLElement} dom - * @param {boolean} value - * @returns {void} - */ -export function autofocus(dom, value) { - if (value) { - const body = document.body; - dom.autofocus = true; - render_effect( - () => { - if (document.activeElement === body) { - dom.focus(); - } - }, - current_block, - true, - false - ); - } -} - -/** - * @template V - * @param {V} value - * @returns {string | V} - */ -export function to_class(value) { - return value == null ? '' : value; -} - -/** - * @param {Element} dom - * @param {string} class_name - * @param {boolean} value - * @returns {void} - */ -export function class_toggle(dom, class_name, value) { - if (value) { - dom.classList.add(class_name); - } else { - dom.classList.remove(class_name); - } -} - -/** - * @param {Element} dom - * @param {string} class_name - * @param {() => boolean} value - * @returns {void} - */ -export function class_toggle_effect(dom, class_name, value) { - render_effect(() => { - const string = value(); - class_toggle(dom, class_name, string); - }); -} - -/** - * @param {Array} events - * @returns {void} - */ -export function delegate(events) { - for (let i = 0; i < events.length; i++) { - all_registered_events.add(events[i]); - } - for (const fn of root_event_handles) { - fn(events); - } -} - -/** - * @param {Node} handler_element - * @param {Event} event - * @returns {void} - */ -function handle_event_propagation(handler_element, event) { - const owner_document = handler_element.ownerDocument; - const event_name = event.type; - const path = event.composedPath?.() || []; - let current_target = /** @type {null | Element} */ (path[0] || event.target); - if (event.target !== current_target) { - define_property(event, 'target', { - configurable: true, - value: current_target - }); - } - - // composedPath contains list of nodes the event has propagated through. - // We check __root to skip all nodes below it in case this is a - // parent of the __root node, which indicates that there's nested - // mounted apps. In this case we don't want to trigger events multiple times. - let path_idx = 0; - // @ts-expect-error is added below - const handled_at = event.__root; - if (handled_at) { - const at_idx = path.indexOf(handled_at); - if ( - at_idx !== -1 && - (handler_element === document || handler_element === /** @type {any} */ (window)) - ) { - // This is the fallback document listener or a window listener, but the event was already handled - // -> ignore, but set handle_at to document/window so that we're resetting the event - // chain in case someone manually dispatches the same event object again. - // @ts-expect-error - event.__root = handler_element; - return; - } - // We're deliberately not skipping if the index is higher, because - // someone could create an event programmatically and emit it multiple times, - // in which case we want to handle the whole propagation chain properly each time. - // (this will only be a false negative if the event is dispatched multiple times and - // the fallback document listener isn't reached in between, but that's super rare) - const handler_idx = path.indexOf(handler_element); - if (handler_idx === -1) { - // handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object) - // so guard against that, too, and assume that everything was handled at this point. - return; - } - if (at_idx <= handler_idx) { - // +1 because at_idx is the element which was already handled, and there can only be one delegated event per element. - // Avoids on:click and onclick on the same event resulting in onclick being fired twice. - path_idx = at_idx + 1; - } - } - - current_target = /** @type {Element} */ (path[path_idx] || event.target); - // Proxy currentTarget to correct target - define_property(event, 'currentTarget', { - configurable: true, - get() { - return current_target || owner_document; - } - }); - - while (current_target !== null) { - /** @type {null | Element} */ - const parent_element = - current_target.parentNode || /** @type {any} */ (current_target).host || null; - const internal_prop_name = '__' + event_name; - // @ts-ignore - const delegated = current_target[internal_prop_name]; - if (delegated !== undefined && !(/** @type {any} */ (current_target).disabled)) { - if (is_array(delegated)) { - const [fn, ...data] = delegated; - fn.apply(current_target, [event, ...data]); - } else { - delegated.call(current_target, event); - } - } - if ( - event.cancelBubble || - parent_element === handler_element || - current_target === handler_element - ) { - break; - } - current_target = parent_element; - } - - // @ts-expect-error is used above - event.__root = handler_element; - // @ts-expect-error is used above - current_target = handler_element; -} - /** * @param {Comment} anchor_node * @param {void | ((anchor: Comment, slot_props: Record) => void)} slot_fn @@ -430,393 +179,6 @@ export function action(dom, action, value_fn) { } }); } -/** - * The value/checked attribute in the template actually corresponds to the defaultValue property, so we need - * to remove it upon hydration to avoid a bug when someone resets the form value. - * @param {HTMLInputElement | HTMLSelectElement} dom - * @returns {void} - */ -export function remove_input_attr_defaults(dom) { - if (hydrating) { - attr(dom, 'value', null); - attr(dom, 'checked', null); - } -} -/** - * The child of a textarea actually corresponds to the defaultValue property, so we need - * to remove it upon hydration to avoid a bug when someone resets the form value. - * @param {HTMLTextAreaElement} dom - * @returns {void} - */ -export function remove_textarea_child(dom) { - if (hydrating && dom.firstChild !== null) { - dom.textContent = ''; - } -} - -/** - * @param {Element} dom - * @param {string} attribute - * @param {() => string} value - */ -export function attr_effect(dom, attribute, value) { - render_effect(() => { - const string = value(); - attr(dom, attribute, string); - }); -} - -/** - * @param {Element} dom - * @param {string} attribute - * @param {string | null} value - */ -export function attr(dom, attribute, value) { - value = value == null ? null : value + ''; - - if (DEV) { - check_src_in_dev_hydration(dom, attribute, value); - } - - if ( - !hydrating || - (dom.getAttribute(attribute) !== value && - // If we reset those, they would result in another network request, which we want to avoid. - // We assume they are the same between client and server as checking if they are equal is expensive - // (we can't just compare the strings as they can be different between client and server but result in the - // same url, so we would need to create hidden anchor elements to compare them) - attribute !== 'src' && - attribute !== 'href' && - attribute !== 'srcset') - ) { - if (value === null) { - dom.removeAttribute(attribute); - } else { - dom.setAttribute(attribute, value); - } - } -} - -/** - * @param {string} element_src - * @param {string} url - * @returns {boolean} - */ -function src_url_equal(element_src, url) { - if (element_src === url) return true; - return new URL(element_src, document.baseURI).href === new URL(url, document.baseURI).href; -} - -/** @param {string} srcset */ -function split_srcset(srcset) { - return srcset.split(',').map((src) => src.trim().split(' ').filter(Boolean)); -} - -/** - * @param {HTMLSourceElement | HTMLImageElement} element - * @param {string | undefined | null} srcset - * @returns {boolean} - */ -export function srcset_url_equal(element, srcset) { - const element_urls = split_srcset(element.srcset); - const urls = split_srcset(srcset ?? ''); - - return ( - urls.length === element_urls.length && - urls.every( - ([url, width], i) => - width === element_urls[i][1] && - // We need to test both ways because Vite will create an a full URL with - // `new URL(asset, import.meta.url).href` for the client when `base: './'`, and the - // relative URLs inside srcset are not automatically resolved to absolute URLs by - // browsers (in contrast to img.src). This means both SSR and DOM code could - // contain relative or absolute URLs. - (src_url_equal(element_urls[i][0], url) || src_url_equal(url, element_urls[i][0])) - ) - ); -} - -/** - * @param {any} dom - * @param {string} attribute - * @param {string | null} value - */ -function check_src_in_dev_hydration(dom, attribute, value) { - if (!hydrating) return; - if (attribute !== 'src' && attribute !== 'href' && attribute !== 'srcset') return; - - if (attribute === 'srcset' && srcset_url_equal(dom, value)) return; - if (src_url_equal(dom.getAttribute(attribute) ?? '', value ?? '')) return; - - // eslint-disable-next-line no-console - console.error( - `Detected a ${attribute} attribute value change during hydration. This will not be repaired during hydration, ` + - `the ${attribute} value that came from the server will be used. Related element:`, - dom, - ' Differing value:', - value - ); -} - -/** - * @param {Element} dom - * @param {string} attribute - * @param {() => string} value - */ -export function xlink_attr_effect(dom, attribute, value) { - render_effect(() => { - const string = value(); - xlink_attr(dom, attribute, string); - }); -} - -/** - * @param {Element} dom - * @param {string} attribute - * @param {string} value - */ -export function xlink_attr(dom, attribute, value) { - dom.setAttributeNS('http://www.w3.org/1999/xlink', attribute, value); -} - -/** - * @param {any} node - * @param {string} prop - * @param {() => any} value - */ -export function set_custom_element_data_effect(node, prop, value) { - render_effect(() => { - set_custom_element_data(node, prop, value()); - }); -} - -/** - * @param {any} node - * @param {string} prop - * @param {any} value - */ -export function set_custom_element_data(node, prop, value) { - if (prop in node) { - node[prop] = typeof node[prop] === 'boolean' && value === '' ? true : value; - } else { - attr(node, prop, value); - } -} - -/** - * @param {HTMLElement} dom - * @param {string} key - * @param {string} value - * @param {boolean} [important] - */ -export function style(dom, key, value, important) { - const style = dom.style; - const prev_value = style.getPropertyValue(key); - if (value == null) { - if (prev_value !== '') { - style.removeProperty(key); - } - } else if (prev_value !== value) { - style.setProperty(key, value, important ? 'important' : ''); - } -} - -/** - * @param {HTMLElement} dom - * @param {string} key - * @param {() => string} value - * @param {boolean} [important] - * @returns {void} - */ -export function style_effect(dom, key, value, important) { - render_effect(() => { - const string = value(); - style(dom, key, string, important); - }); -} - -/** - * List of attributes that should always be set through the attr method, - * because updating them through the property setter doesn't work reliably. - * In the example of `width`/`height`, the problem is that the setter only - * accepts numeric values, but the attribute can also be set to a string like `50%`. - * If this list becomes too big, rethink this approach. - */ -const always_set_through_set_attribute = ['width', 'height']; - -/** @type {Map} */ -const setters_cache = new Map(); - -/** @param {Element} element */ -function get_setters(element) { - /** @type {string[]} */ - const setters = []; - // @ts-expect-error - const descriptors = get_descriptors(element.__proto__); - for (const key in descriptors) { - if (descriptors[key].set && !always_set_through_set_attribute.includes(key)) { - setters.push(key); - } - } - return setters; -} - -/** - * Like `spread_attributes` but self-contained - * @param {Element & ElementCSSInlineStyle} dom - * @param {() => Record[]} attrs - * @param {boolean} lowercase_attributes - * @param {string} css_hash - */ -export function spread_attributes_effect(dom, attrs, lowercase_attributes, css_hash) { - /** @type {Record | undefined} */ - let current = undefined; - - render_effect(() => { - current = spread_attributes(dom, current, attrs(), lowercase_attributes, css_hash); - }); -} - -/** - * Spreads attributes onto a DOM element, taking into account the currently set attributes - * @param {Element & ElementCSSInlineStyle} dom - * @param {Record | undefined} prev - * @param {Record[]} attrs - * @param {boolean} lowercase_attributes - * @param {string} css_hash - * @returns {Record} - */ -export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_hash) { - const next = object_assign({}, ...attrs); - const has_hash = css_hash.length !== 0; - for (const key in prev) { - if (!(key in next)) { - next[key] = null; - } - } - if (has_hash && !next.class) { - next.class = ''; - } - - let setters = map_get(setters_cache, dom.nodeName); - if (!setters) map_set(setters_cache, dom.nodeName, (setters = get_setters(dom))); - - for (const key in next) { - let value = next[key]; - if (value === prev?.[key]) continue; - - const prefix = key[0] + key[1]; // this is faster than key.slice(0, 2) - if (prefix === '$$') continue; - - if (prefix === 'on') { - /** @type {{ capture?: true }} */ - const opts = {}; - let event_name = key.slice(2); - const delegated = DelegatedEvents.includes(event_name); - - if ( - event_name.endsWith('capture') && - event_name !== 'ongotpointercapture' && - event_name !== 'onlostpointercapture' - ) { - event_name = event_name.slice(0, -7); - opts.capture = true; - } - if (!delegated && prev?.[key]) { - dom.removeEventListener(event_name, /** @type {any} */ (prev[key]), opts); - } - if (value != null) { - if (!delegated) { - dom.addEventListener(event_name, value, opts); - } else { - // @ts-ignore - dom[`__${event_name}`] = value; - delegate([event_name]); - } - } - } else if (value == null) { - dom.removeAttribute(key); - } else if (key === 'style') { - dom.style.cssText = value + ''; - } else if (key === 'autofocus') { - autofocus(/** @type {HTMLElement} */ (dom), Boolean(value)); - } else if (key === '__value' || key === 'value') { - // @ts-ignore - dom.value = dom[key] = dom.__value = value; - } else { - let name = key; - if (lowercase_attributes) { - name = name.toLowerCase(); - name = AttributeAliases[name] || name; - } - - if (setters.includes(name)) { - if (DEV) { - check_src_in_dev_hydration(dom, name, value); - } - if ( - !hydrating || - // @ts-ignore see attr method for an explanation of src/srcset - (dom[name] !== value && name !== 'src' && name !== 'href' && name !== 'srcset') - ) { - // @ts-ignore - dom[name] = value; - } - } else if (typeof value !== 'function') { - if (has_hash && name === 'class') { - if (value) value += ' '; - value += css_hash; - } - - attr(dom, name, value); - } - } - } - return next; -} - -/** - * @param {Element} node - * @param {() => Record[]} attrs - * @param {string} css_hash - */ -export function spread_dynamic_element_attributes_effect(node, attrs, css_hash) { - /** @type {Record | undefined} */ - let current = undefined; - - render_effect(() => { - current = spread_dynamic_element_attributes(node, current, attrs(), css_hash); - }); -} - -/** - * @param {Element} node - * @param {Record | undefined} prev - * @param {Record[]} attrs - * @param {string} css_hash - */ -export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) { - if (node.tagName.includes('-')) { - const next = object_assign({}, ...attrs); - for (const key in prev) { - if (!(key in next)) { - next[key] = null; - } - } - for (const key in next) { - set_custom_element_data(node, key, next[key]); - } - return next; - } else { - return spread_attributes( - /** @type {Element & ElementCSSInlineStyle} */ (node), - prev, - attrs, - node.namespaceURI !== namespace_svg, - css_hash - ); - } -} // TODO 5.0 remove this /** diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index 2872a23f1e83..8ef8ee18ff0d 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -40,6 +40,11 @@ export * from './client/dom/blocks/snippet.js'; export * from './client/dom/blocks/svelte-component.js'; export * from './client/dom/blocks/svelte-element.js'; export * from './client/dom/blocks/svelte-head.js'; +export * from './client/dom/elements/attributes.js'; +export * from './client/dom/elements/class.js'; +export * from './client/dom/elements/events.js'; +export * from './client/dom/elements/misc.js'; +export * from './client/dom/elements/style.js'; export * from './client/dom/legacy/event-modifiers.js'; export * from './client/dom/legacy/lifecycle.js'; export * from './client/dom/legacy/misc.js'; From fe6c2c1d7cea95985c64a4b6ae4ef2b73e3578b5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 12 Mar 2024 14:29:42 -0400 Subject: [PATCH 2/2] eslint nonsense --- .../svelte/src/internal/client/dom/elements/attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 94c126617e57..5e5a530eb077 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -148,7 +148,7 @@ export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_ha var setters = map_get(setters_cache, dom.nodeName); if (!setters) map_set(setters_cache, dom.nodeName, (setters = get_setters(dom))); - for (var key in next) { + for (key in next) { var value = next[key]; if (value === prev?.[key]) continue; @@ -256,7 +256,7 @@ export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) { } } - for (var key in next) { + for (key in next) { set_custom_element_data(node, key, next[key]); }