move element code (#10778)

* move element code

* eslint nonsense

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/10770/head
Rich Harris 10 months ago committed by GitHub
parent 84fa18a850
commit de021aebb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {string} css_hash
*/
export function spread_attributes_effect(dom, attrs, lowercase_attributes, css_hash) {
/** @type {Record<string, any> | 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<string, unknown> | undefined} prev
* @param {Record<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {string} css_hash
* @returns {Record<string, unknown>}
*/
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 (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<string, unknown>[]} attrs
* @param {string} css_hash
*/
export function spread_dynamic_element_attributes_effect(node, attrs, css_hash) {
/** @type {Record<string, any> | undefined} */
var current;
render_effect(() => {
current = spread_dynamic_element_attributes(node, current, attrs(), css_hash);
});
}
/**
* @param {Element} node
* @param {Record<string, unknown> | undefined} prev
* @param {Record<string, unknown>[]} 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 (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<string, string[]>} */
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]))
)
);
}

@ -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());
});
}

@ -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<string>} 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;
}

@ -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 = '';
}
}

@ -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);
});
}

@ -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<string>} */
const all_registered_events = new Set();
export const all_registered_events = new Set();
/** @type {Set<(events: Array<string>) => 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<string>} 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<string, unknown>) => 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<string, string[]>} */
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<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {string} css_hash
*/
export function spread_attributes_effect(dom, attrs, lowercase_attributes, css_hash) {
/** @type {Record<string, any> | 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<string, unknown> | undefined} prev
* @param {Record<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {string} css_hash
* @returns {Record<string, unknown>}
*/
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<string, unknown>[]} attrs
* @param {string} css_hash
*/
export function spread_dynamic_element_attributes_effect(node, attrs, css_hash) {
/** @type {Record<string, any> | undefined} */
let current = undefined;
render_effect(() => {
current = spread_dynamic_element_attributes(node, current, attrs(), css_hash);
});
}
/**
* @param {Element} node
* @param {Record<string, unknown> | undefined} prev
* @param {Record<string, unknown>[]} 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
/**

@ -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';

Loading…
Cancel
Save