mirror of https://github.com/sveltejs/svelte
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2635 lines
71 KiB
2635 lines
71 KiB
import { DEV } from 'esm-env';
|
|
import {
|
|
append_child,
|
|
child,
|
|
clone_node,
|
|
create_element,
|
|
empty,
|
|
init_operations,
|
|
map_get,
|
|
map_set,
|
|
set_class_name
|
|
} from './operations.js';
|
|
import {
|
|
PassiveDelegatedEvents,
|
|
DelegatedEvents,
|
|
AttributeAliases,
|
|
namespace_svg,
|
|
PROPS_IS_IMMUTABLE,
|
|
PROPS_IS_RUNES,
|
|
PROPS_IS_UPDATED,
|
|
PROPS_IS_LAZY_INITIAL
|
|
} from '../../constants.js';
|
|
import {
|
|
create_fragment_from_html,
|
|
create_fragment_with_script_from_html,
|
|
insert,
|
|
reconcile_html,
|
|
remove
|
|
} from './reconciler.js';
|
|
import {
|
|
destroy_signal,
|
|
push_destroy_fn,
|
|
untrack,
|
|
flush_sync,
|
|
push,
|
|
pop,
|
|
current_component_context,
|
|
deep_read,
|
|
get,
|
|
set,
|
|
is_signals_recorded,
|
|
inspect_fn,
|
|
current_effect
|
|
} from './runtime.js';
|
|
import {
|
|
render_effect,
|
|
effect,
|
|
managed_effect,
|
|
derived,
|
|
pre_effect,
|
|
user_effect,
|
|
destroy_effect
|
|
} from './reactivity/computations.js';
|
|
import {
|
|
current_hydration_fragment,
|
|
get_hydration_fragment,
|
|
hydrate_block_anchor,
|
|
hydrating,
|
|
set_current_hydration_fragment
|
|
} from './hydration.js';
|
|
import {
|
|
array_from,
|
|
define_property,
|
|
get_descriptor,
|
|
get_descriptors,
|
|
is_array,
|
|
is_function,
|
|
object_assign
|
|
} from './utils.js';
|
|
import { run } from '../common.js';
|
|
import { bind_transition } from './transitions.js';
|
|
import { mutable_source, source } from './reactivity/sources.js';
|
|
import { safe_equal, safe_not_equal } from './reactivity/equality.js';
|
|
|
|
/** @type {Set<string>} */
|
|
const all_registered_events = new Set();
|
|
|
|
/** @type {Set<(events: Array<string>) => void>} */
|
|
const root_event_handles = new Set();
|
|
|
|
export let run_transitions = true;
|
|
|
|
/**
|
|
* @param {string} html
|
|
* @param {boolean} return_fragment
|
|
* @returns {() => Node}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function template(html, return_fragment) {
|
|
/** @type {undefined | Node} */
|
|
let cached_content;
|
|
return () => {
|
|
if (cached_content === undefined) {
|
|
const content = create_fragment_from_html(html);
|
|
cached_content = return_fragment ? content : /** @type {Node} */ (child(content));
|
|
}
|
|
return cached_content;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} html
|
|
* @param {boolean} return_fragment
|
|
* @returns {() => Node}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function template_with_script(html, return_fragment) {
|
|
/** @type {undefined | Node} */
|
|
let cached_content;
|
|
return () => {
|
|
if (cached_content === undefined) {
|
|
const content = create_fragment_with_script_from_html(html);
|
|
cached_content = return_fragment ? content : /** @type {Node} */ (child(content));
|
|
}
|
|
return cached_content;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} svg
|
|
* @param {boolean} return_fragment
|
|
* @returns {() => Node}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function svg_template(svg, return_fragment) {
|
|
/** @type {undefined | Node} */
|
|
let cached_content;
|
|
return () => {
|
|
if (cached_content === undefined) {
|
|
const content = /** @type {Node} */ (child(create_fragment_from_html(`<svg>${svg}</svg>`)));
|
|
cached_content = return_fragment ? content : /** @type {Node} */ (child(content));
|
|
}
|
|
return cached_content;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} svg
|
|
* @param {boolean} return_fragment
|
|
* @returns {() => Node}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function svg_template_with_script(svg, return_fragment) {
|
|
/** @type {undefined | Node} */
|
|
let cached_content;
|
|
return () => {
|
|
if (cached_content === undefined) {
|
|
const content = /** @type {Node} */ (child(create_fragment_from_html(`<svg>${svg}</svg>`)));
|
|
cached_content = return_fragment ? content : /** @type {Node} */ (child(content));
|
|
}
|
|
return cached_content;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Element} node
|
|
* @returns {Element}
|
|
*/
|
|
export function svg_replace(node) {
|
|
const first_child = /** @type {Element} */ (node.firstChild);
|
|
node.replaceWith(first_child);
|
|
return first_child;
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} is_fragment
|
|
* @param {boolean} use_clone_node
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @param {() => Node} [template_element_fn]
|
|
* @returns {Element | DocumentFragment | Node[]}
|
|
*/
|
|
function open_template(is_fragment, use_clone_node, anchor, template_element_fn) {
|
|
if (hydrating) {
|
|
if (anchor !== null) {
|
|
hydrate_block_anchor(anchor, false);
|
|
}
|
|
// In ssr+hydration optimization mode, we might remove the template_element,
|
|
// so we need to is_fragment flag to properly handle hydrated content accordingly.
|
|
const fragment = current_hydration_fragment;
|
|
if (fragment !== null) {
|
|
return is_fragment ? fragment : /** @type {Element} */ (fragment[0]);
|
|
}
|
|
}
|
|
return use_clone_node
|
|
? clone_node(/** @type {() => Element} */ (template_element_fn)(), true)
|
|
: document.importNode(/** @type {() => Element} */ (template_element_fn)(), true);
|
|
}
|
|
|
|
/**
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @param {boolean} use_clone_node
|
|
* @param {() => Node} [template_element_fn]
|
|
* @returns {Element | DocumentFragment | Node[]}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function open(anchor, use_clone_node, template_element_fn) {
|
|
return open_template(false, use_clone_node, anchor, template_element_fn);
|
|
}
|
|
|
|
/**
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @param {boolean} use_clone_node
|
|
* @param {() => Node} [template_element_fn]
|
|
* @returns {Element | DocumentFragment | Node[]}
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function open_frag(anchor, use_clone_node, template_element_fn) {
|
|
return open_template(true, use_clone_node, anchor, template_element_fn);
|
|
}
|
|
|
|
const space_template = template(' ', false);
|
|
const comment_template = template('<!>', true);
|
|
|
|
/**
|
|
* @param {Text | Comment | Element | null} anchor
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function space_frag(anchor) {
|
|
/** @type {Node | null} */
|
|
var node = /** @type {any} */ (open(anchor, true, space_template));
|
|
// if an {expression} is empty during SSR, there might be no
|
|
// text node to hydrate (or an anchor comment is falsely detected instead)
|
|
// — we must therefore create one
|
|
if (hydrating && node?.nodeType !== 3) {
|
|
node = empty();
|
|
// @ts-ignore in this case the anchor should always be a comment,
|
|
// if not something more fundamental is wrong and throwing here is better to bail out early
|
|
anchor.before(node);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* @param {Text | Comment | Element} anchor
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function space(anchor) {
|
|
// if an {expression} is empty during SSR, there might be no
|
|
// text node to hydrate (or an anchor comment is falsely detected instead)
|
|
// — we must therefore create one
|
|
if (hydrating && anchor.nodeType !== 3) {
|
|
const node = empty();
|
|
anchor.before(node);
|
|
return node;
|
|
}
|
|
return anchor;
|
|
}
|
|
|
|
/**
|
|
* @param {null | Text | Comment | Element} anchor
|
|
*/
|
|
/*#__NO_SIDE_EFFECTS__*/
|
|
export function comment(anchor) {
|
|
return open_frag(anchor, true, comment_template);
|
|
}
|
|
|
|
/**
|
|
* Assign the created (or in hydration mode, traversed) dom elements to the current block
|
|
* and insert the elements into the dom (in client mode).
|
|
* @param {Element | Text} dom
|
|
* @param {boolean} is_fragment
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @returns {void}
|
|
*/
|
|
function close_template(dom, is_fragment, anchor) {
|
|
const effect = /** @type {import('./types.js').BlockEffect} */ (current_effect);
|
|
|
|
/** @type {import('./types.js').TemplateNode | Array<import('./types.js').TemplateNode>} */
|
|
const current = is_fragment
|
|
? is_array(dom)
|
|
? dom
|
|
: /** @type {import('./types.js').TemplateNode[]} */ (Array.from(dom.childNodes))
|
|
: dom;
|
|
if (!hydrating && anchor !== null) {
|
|
insert(current, null, anchor);
|
|
}
|
|
|
|
effect.dom = current;
|
|
}
|
|
|
|
/**
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @param {Element | Text} dom
|
|
* @returns {void}
|
|
*/
|
|
export function close(anchor, dom) {
|
|
close_template(dom, false, anchor);
|
|
}
|
|
|
|
/**
|
|
* @param {null | Text | Comment | Element} anchor
|
|
* @param {Element | Text} dom
|
|
* @returns {void}
|
|
*/
|
|
export function close_frag(anchor, dom) {
|
|
close_template(dom, true, anchor);
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function trusted(fn) {
|
|
return function (...args) {
|
|
const event = /** @type {Event} */ (args[0]);
|
|
if (event.isTrusted) {
|
|
// @ts-ignore
|
|
fn.apply(this, args);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function self(fn) {
|
|
return function (...args) {
|
|
const event = /** @type {Event} */ (args[0]);
|
|
// @ts-ignore
|
|
if (event.target === this) {
|
|
// @ts-ignore
|
|
fn.apply(this, args);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function stopPropagation(fn) {
|
|
return function (...args) {
|
|
const event = /** @type {Event} */ (args[0]);
|
|
event.stopPropagation();
|
|
// @ts-ignore
|
|
return fn.apply(this, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function once(fn) {
|
|
let ran = false;
|
|
return function (...args) {
|
|
if (ran) {
|
|
return;
|
|
}
|
|
ran = true;
|
|
// @ts-ignore
|
|
return fn.apply(this, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function stopImmediatePropagation(fn) {
|
|
return function (...args) {
|
|
const event = /** @type {Event} */ (args[0]);
|
|
event.stopImmediatePropagation();
|
|
// @ts-ignore
|
|
return fn.apply(this, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(event: Event, ...args: Array<unknown>) => void} fn
|
|
* @returns {(event: Event, ...args: unknown[]) => void}
|
|
*/
|
|
export function preventDefault(fn) {
|
|
return function (...args) {
|
|
const event = /** @type {Event} */ (args[0]);
|
|
event.preventDefault();
|
|
// @ts-ignore
|
|
return fn.apply(this, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Element} dom
|
|
* @param {() => string} value
|
|
* @returns {void}
|
|
*/
|
|
export function text_effect(dom, value) {
|
|
render_effect(() => text(dom, value()));
|
|
}
|
|
|
|
/**
|
|
* @param {Element} dom
|
|
* @param {string} value
|
|
* @returns {void}
|
|
*/
|
|
export function text(dom, value) {
|
|
// @ts-expect-error need to add __value to patched prototype
|
|
const prev_node_value = dom.__nodeValue;
|
|
const next_node_value = stringify(value);
|
|
if (hydrating && dom.nodeValue === next_node_value) {
|
|
// In case of hydration don't reset the nodeValue as it's already correct.
|
|
// @ts-expect-error need to add __nodeValue to patched prototype
|
|
dom.__nodeValue = next_node_value;
|
|
} else if (prev_node_value !== next_node_value) {
|
|
dom.nodeValue = next_node_value;
|
|
// @ts-expect-error need to add __className to patched prototype
|
|
dom.__nodeValue = next_node_value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} element
|
|
* @param {boolean} value
|
|
* @returns {void}
|
|
*/
|
|
export function autofocus(element, value) {
|
|
if (value) {
|
|
element.autofocus = true;
|
|
|
|
user_effect(() => {
|
|
if (document.activeElement === document.body) {
|
|
element.focus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Selects the correct option(s) (depending on whether this is a multiple select)
|
|
* @template V
|
|
* @param {HTMLSelectElement} select
|
|
* @param {V} value
|
|
* @param {boolean} [mounting]
|
|
*/
|
|
export function select_option(select, value, mounting) {
|
|
if (select.multiple) {
|
|
return select_options(select, value);
|
|
}
|
|
for (const option of select.options) {
|
|
const option_value = get_option_value(option);
|
|
if (option_value === value) {
|
|
option.selected = true;
|
|
return;
|
|
}
|
|
}
|
|
if (!mounting || value !== undefined) {
|
|
select.selectedIndex = -1; // no option should be selected
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template V
|
|
* @param {HTMLSelectElement} select
|
|
* @param {V} value
|
|
*/
|
|
function select_options(select, value) {
|
|
for (const option of select.options) {
|
|
// @ts-ignore
|
|
option.selected = ~value.indexOf(get_option_value(option));
|
|
}
|
|
}
|
|
|
|
/** @param {HTMLOptionElement} option */
|
|
function get_option_value(option) {
|
|
// __value only exists if the <option> has a value attribute
|
|
if ('__value' in option) {
|
|
return option.__value;
|
|
} else {
|
|
return option.value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {(online: boolean) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_online(update) {
|
|
const status_changed = () => {
|
|
update(navigator.onLine);
|
|
};
|
|
listen_to_events(window, ['online', 'offline'], status_changed);
|
|
}
|
|
|
|
/** @param {TimeRanges} ranges */
|
|
function time_ranges_to_array(ranges) {
|
|
const array = [];
|
|
for (let i = 0; i < ranges.length; i += 1) {
|
|
array.push({ start: ranges.start(i), end: ranges.end(i) });
|
|
}
|
|
return array;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {() => number | undefined} get_value
|
|
* @param {(value: number) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_current_time(media, get_value, update) {
|
|
/** @type {number} */
|
|
let raf_id;
|
|
let updating = false;
|
|
// Ideally, listening to timeupdate would be enough, but it fires too infrequently for the currentTime
|
|
// binding, which is why we use a raf loop, too. We additionally still listen to timeupdate because
|
|
// the user could be scrubbing through the video using the native controls when the media is paused.
|
|
const callback = () => {
|
|
cancelAnimationFrame(raf_id);
|
|
if (!media.paused) {
|
|
raf_id = requestAnimationFrame(callback);
|
|
}
|
|
updating = true;
|
|
update(media.currentTime);
|
|
};
|
|
raf_id = requestAnimationFrame(callback);
|
|
media.addEventListener('timeupdate', callback);
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
// through isNaN we also allow number strings, which is more robust
|
|
if (!updating && !isNaN(/** @type {any} */ (value))) {
|
|
media.currentTime = /** @type {number} */ (value);
|
|
}
|
|
updating = false;
|
|
});
|
|
render_effect(() => () => cancelAnimationFrame(raf_id));
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(array: Array<{ start: number; end: number }>) => void} update
|
|
*/
|
|
export function bind_buffered(media, update) {
|
|
const callback = () => {
|
|
update(time_ranges_to_array(media.buffered));
|
|
};
|
|
listen_to_events(media, ['loadedmetadata', 'progress'], callback);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(array: Array<{ start: number; end: number }>) => void} update
|
|
*/
|
|
export function bind_seekable(media, update) {
|
|
const callback = () => {
|
|
update(time_ranges_to_array(media.seekable));
|
|
};
|
|
listen_to_events(media, ['loadedmetadata'], callback);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(array: Array<{ start: number; end: number }>) => void} update
|
|
*/
|
|
export function bind_played(media, update) {
|
|
const callback = () => {
|
|
update(time_ranges_to_array(media.played));
|
|
};
|
|
listen_to_events(media, ['timeupdate'], callback);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(seeking: boolean) => void} update
|
|
*/
|
|
export function bind_seeking(media, update) {
|
|
const callback = () => {
|
|
update(media.seeking);
|
|
};
|
|
listen_to_events(media, ['seeking', 'seeked'], callback);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(seeking: boolean) => void} update
|
|
*/
|
|
export function bind_ended(media, update) {
|
|
const callback = () => {
|
|
update(media.ended);
|
|
};
|
|
listen_to_events(media, ['timeupdate', 'ended'], callback);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {(ready_state: number) => void} update
|
|
*/
|
|
export function bind_ready_state(media, update) {
|
|
const callback = () => {
|
|
update(media.readyState);
|
|
};
|
|
listen_to_events(
|
|
media,
|
|
['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'playing', 'waiting', 'emptied'],
|
|
callback
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {() => number | undefined} get_value
|
|
* @param {(playback_rate: number) => void} update
|
|
*/
|
|
export function bind_playback_rate(media, get_value, update) {
|
|
let updating = false;
|
|
const callback = () => {
|
|
if (!updating) {
|
|
update(media.playbackRate);
|
|
}
|
|
updating = false;
|
|
};
|
|
// Needs to happen after the element is inserted into the dom, else playback will be set back to 1 by the browser.
|
|
// For hydration we could do it immediately but the additional code is not worth the lost microtask.
|
|
|
|
/** @type {import('./types.js').ComputationSignal | undefined} */
|
|
let render;
|
|
let destroyed = false;
|
|
const effect = managed_effect(() => {
|
|
destroy_signal(effect);
|
|
if (destroyed) return;
|
|
if (get_value() == null) {
|
|
callback();
|
|
}
|
|
listen_to_events(media, ['ratechange'], callback, false);
|
|
render = render_effect(() => {
|
|
const value = get_value();
|
|
// through isNaN we also allow number strings, which is more robust
|
|
if (!isNaN(/** @type {any} */ (value)) && value !== media.playbackRate) {
|
|
updating = true;
|
|
media.playbackRate = /** @type {number} */ (value);
|
|
}
|
|
});
|
|
});
|
|
render_effect(() => () => {
|
|
destroyed = true;
|
|
if (render) {
|
|
destroy_signal(render);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {() => boolean | undefined} get_value
|
|
* @param {(paused: boolean) => void} update
|
|
*/
|
|
export function bind_paused(media, get_value, update) {
|
|
let mounted = hydrating;
|
|
let paused = get_value();
|
|
const callback = () => {
|
|
if (paused !== media.paused) {
|
|
paused = media.paused;
|
|
update((paused = media.paused));
|
|
}
|
|
};
|
|
if (paused == null) {
|
|
callback();
|
|
}
|
|
// Defer listening if not mounted yet so that the first canplay event doesn't cause a potentially wrong update
|
|
if (mounted) {
|
|
// If someone switches the src while media is playing, the player will pause.
|
|
// Listen to the canplay event to get notified of this situation.
|
|
listen_to_events(media, ['play', 'pause', 'canplay'], callback, false);
|
|
}
|
|
render_effect(() => {
|
|
paused = !!get_value();
|
|
if (paused !== media.paused) {
|
|
const toggle = () => {
|
|
mounted = true;
|
|
if (paused) {
|
|
media.pause();
|
|
} else {
|
|
media.play().catch(() => {
|
|
update((paused = true));
|
|
});
|
|
}
|
|
};
|
|
if (mounted) {
|
|
toggle();
|
|
} else {
|
|
// If this is the first invocation in dom mode, the media element isn't mounted yet,
|
|
// and therefore its resource isn't loaded yet. We need to wait for the canplay event
|
|
// in this case or else we'll get a "The play() request was interrupted by a new load request" error.
|
|
media.addEventListener(
|
|
'canplay',
|
|
() => {
|
|
listen_to_events(media, ['play', 'pause', 'canplay'], callback, false);
|
|
toggle();
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {() => number | undefined} get_value
|
|
* @param {(volume: number) => void} update
|
|
*/
|
|
export function bind_volume(media, get_value, update) {
|
|
let updating = false;
|
|
const callback = () => {
|
|
updating = true;
|
|
update(media.volume);
|
|
};
|
|
if (get_value() == null) {
|
|
callback();
|
|
}
|
|
listen_to_events(media, ['volumechange'], callback, false);
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
// through isNaN we also allow number strings, which is more robust
|
|
if (!updating && !isNaN(/** @type {any} */ (value))) {
|
|
media.volume = /** @type {number} */ (value);
|
|
}
|
|
updating = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLVideoElement | HTMLAudioElement} media
|
|
* @param {() => boolean | undefined} get_value
|
|
* @param {(muted: boolean) => void} update
|
|
*/
|
|
export function bind_muted(media, get_value, update) {
|
|
let updating = false;
|
|
const callback = () => {
|
|
updating = true;
|
|
update(media.muted);
|
|
};
|
|
if (get_value() == null) {
|
|
callback();
|
|
}
|
|
listen_to_events(media, ['volumechange'], callback, false);
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
if (!updating) {
|
|
media.muted = !!value;
|
|
}
|
|
updating = false;
|
|
});
|
|
}
|
|
/**
|
|
* Fires the handler once immediately (unless corresponding arg is set to `false`),
|
|
* then listens to the given events until the render effect context is destroyed
|
|
* @param {Element | Window} dom
|
|
* @param {Array<string>} events
|
|
* @param {() => void} handler
|
|
* @param {any} call_handler_immediately
|
|
*/
|
|
function listen_to_events(dom, events, handler, call_handler_immediately = true) {
|
|
if (call_handler_immediately) {
|
|
handler();
|
|
}
|
|
for (const name of events) {
|
|
dom.addEventListener(name, handler);
|
|
}
|
|
render_effect(() => {
|
|
return () => {
|
|
for (const name of events) {
|
|
dom.removeEventListener(name, handler);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* Resize observer singleton.
|
|
* One listener per element only!
|
|
* https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ
|
|
*/
|
|
class ResizeObserverSingleton {
|
|
/** */
|
|
#listeners = new WeakMap();
|
|
|
|
/** @type {ResizeObserver | undefined} */
|
|
#observer;
|
|
|
|
/** @type {ResizeObserverOptions} */
|
|
#options;
|
|
|
|
/** @static */
|
|
static entries = new WeakMap();
|
|
|
|
/** @param {ResizeObserverOptions} options */
|
|
constructor(options) {
|
|
this.#options = options;
|
|
}
|
|
|
|
/**
|
|
* @param {Element} element
|
|
* @param {(entry: ResizeObserverEntry) => any} listener
|
|
*/
|
|
observe(element, listener) {
|
|
const listeners = this.#listeners.get(element) || new Set();
|
|
listeners.add(listener);
|
|
this.#listeners.set(element, listeners);
|
|
this.#getObserver().observe(element, this.#options);
|
|
return () => {
|
|
const listeners = this.#listeners.get(element);
|
|
listeners.delete(listener);
|
|
if (listeners.size === 0) {
|
|
this.#listeners.delete(element);
|
|
/** @type {ResizeObserver} */ (this.#observer).unobserve(element);
|
|
}
|
|
};
|
|
}
|
|
#getObserver() {
|
|
return (
|
|
this.#observer ??
|
|
(this.#observer = new ResizeObserver(
|
|
/** @param {any} entries */ (entries) => {
|
|
for (const entry of entries) {
|
|
ResizeObserverSingleton.entries.set(entry.target, entry);
|
|
for (const listener of this.#listeners.get(entry.target) || []) {
|
|
listener(entry);
|
|
}
|
|
}
|
|
}
|
|
))
|
|
);
|
|
}
|
|
}
|
|
const resize_observer_content_box = /* @__PURE__ */ new ResizeObserverSingleton({
|
|
box: 'content-box'
|
|
});
|
|
const resize_observer_border_box = /* @__PURE__ */ new ResizeObserverSingleton({
|
|
box: 'border-box'
|
|
});
|
|
const resize_observer_device_pixel_content_box = /* @__PURE__ */ new ResizeObserverSingleton({
|
|
box: 'device-pixel-content-box'
|
|
});
|
|
|
|
/**
|
|
* @param {Element} dom
|
|
* @param {'contentRect' | 'contentBoxSize' | 'borderBoxSize' | 'devicePixelContentBoxSize'} type
|
|
* @param {(entry: keyof ResizeObserverEntry) => void} update
|
|
*/
|
|
export function bind_resize_observer(dom, type, update) {
|
|
const observer =
|
|
type === 'contentRect' || type === 'contentBoxSize'
|
|
? resize_observer_content_box
|
|
: type === 'borderBoxSize'
|
|
? resize_observer_border_box
|
|
: resize_observer_device_pixel_content_box;
|
|
const unsub = observer.observe(dom, /** @param {any} entry */ (entry) => update(entry[type]));
|
|
render_effect(() => unsub);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} dom
|
|
* @param {'clientWidth' | 'clientHeight' | 'offsetWidth' | 'offsetHeight'} type
|
|
* @param {(size: number) => void} update
|
|
*/
|
|
export function bind_element_size(dom, type, update) {
|
|
const unsub = resize_observer_border_box.observe(dom, () => update(dom[type]));
|
|
effect(() => {
|
|
untrack(() => update(dom[type]));
|
|
return unsub;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {'innerWidth' | 'innerHeight' | 'outerWidth' | 'outerHeight'} type
|
|
* @param {(size: number) => void} update
|
|
*/
|
|
export function bind_window_size(type, update) {
|
|
const callback = () => update(window[type]);
|
|
listen_to_events(window, ['resize'], callback);
|
|
}
|
|
|
|
/**
|
|
* Finds the containing `<select>` element and potentially updates its `selected` state.
|
|
* @param {HTMLOptionElement} dom
|
|
* @returns {void}
|
|
*/
|
|
export function selected(dom) {
|
|
// Inside an effect because the element might not be connected
|
|
// to the parent <select> yet when this is called
|
|
effect(() => {
|
|
let select = dom.parentNode;
|
|
while (select != null) {
|
|
if (select.nodeName === 'SELECT') {
|
|
break;
|
|
}
|
|
select = select.parentNode;
|
|
}
|
|
// @ts-ignore
|
|
if (select != null && dom.__value === select.__value) {
|
|
// never set to false, since this causes browser to select default option
|
|
dom.selected = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLInputElement} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_value(dom, get_value, update) {
|
|
dom.addEventListener('input', () => {
|
|
/** @type {any} */
|
|
let value = dom.value;
|
|
if (is_numberlike_input(dom)) {
|
|
value = to_number(value);
|
|
}
|
|
update(value);
|
|
});
|
|
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
// @ts-ignore
|
|
dom.__value = value;
|
|
|
|
if (is_numberlike_input(dom) && value === to_number(dom.value)) {
|
|
// handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959)
|
|
return;
|
|
}
|
|
|
|
dom.value = stringify(value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLInputElement} dom
|
|
*/
|
|
function is_numberlike_input(dom) {
|
|
const type = dom.type;
|
|
return type === 'number' || type === 'range';
|
|
}
|
|
|
|
/**
|
|
* @param {string} value
|
|
*/
|
|
function to_number(value) {
|
|
return value === '' ? null : +value;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLSelectElement} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_select_value(dom, get_value, update) {
|
|
let mounting = true;
|
|
dom.addEventListener('change', () => {
|
|
/** @type {unknown} */
|
|
let value;
|
|
if (dom.multiple) {
|
|
value = [].map.call(dom.querySelectorAll(':checked'), get_option_value);
|
|
} else {
|
|
/** @type {HTMLOptionElement | null} */
|
|
const selected_option = dom.querySelector(':checked');
|
|
value = selected_option && get_option_value(selected_option);
|
|
}
|
|
update(value);
|
|
});
|
|
// Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated
|
|
effect(() => {
|
|
let value = get_value();
|
|
select_option(dom, value, mounting);
|
|
if (mounting && value === undefined) {
|
|
/** @type {HTMLOptionElement | null} */
|
|
let selected_option = dom.querySelector(':checked');
|
|
if (selected_option !== null) {
|
|
value = get_option_value(selected_option);
|
|
update(value);
|
|
}
|
|
}
|
|
// @ts-ignore
|
|
dom.__value = value;
|
|
mounting = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {'innerHTML' | 'textContent' | 'innerText'} property
|
|
* @param {HTMLElement} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_content_editable(property, dom, get_value, update) {
|
|
dom.addEventListener('input', () => {
|
|
// @ts-ignore
|
|
const value = dom[property];
|
|
update(value);
|
|
});
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
if (dom[property] !== value) {
|
|
if (value === null) {
|
|
// @ts-ignore
|
|
const non_null_value = dom[property];
|
|
update(non_null_value);
|
|
} else {
|
|
// @ts-ignore
|
|
dom[property] = value + '';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @template V
|
|
* @param {Array<HTMLInputElement>} group
|
|
* @param {V} __value
|
|
* @param {boolean} checked
|
|
* @returns {V[]}
|
|
*/
|
|
function get_binding_group_value(group, __value, checked) {
|
|
const value = new Set();
|
|
for (let i = 0; i < group.length; i += 1) {
|
|
if (group[i].checked) {
|
|
// @ts-ignore
|
|
value.add(group[i].__value);
|
|
}
|
|
}
|
|
if (!checked) {
|
|
value.delete(__value);
|
|
}
|
|
return Array.from(value);
|
|
}
|
|
|
|
/**
|
|
* @param {Array<HTMLInputElement>} group
|
|
* @param {null | [number]} group_index
|
|
* @param {HTMLInputElement} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_group(group, group_index, dom, get_value, update) {
|
|
const is_checkbox = dom.getAttribute('type') === 'checkbox';
|
|
let binding_group = group;
|
|
if (group_index !== null) {
|
|
for (const index of group_index) {
|
|
const group = binding_group;
|
|
// @ts-ignore
|
|
binding_group = group[index];
|
|
if (binding_group === undefined) {
|
|
// @ts-ignore
|
|
binding_group = group[index] = [];
|
|
}
|
|
}
|
|
}
|
|
binding_group.push(dom);
|
|
dom.addEventListener('change', () => {
|
|
// @ts-ignore
|
|
let value = dom.__value;
|
|
if (is_checkbox) {
|
|
value = get_binding_group_value(binding_group, value, dom.checked);
|
|
}
|
|
update(value);
|
|
});
|
|
render_effect(() => {
|
|
let value = get_value();
|
|
if (is_checkbox) {
|
|
value = value || [];
|
|
// @ts-ignore
|
|
dom.checked = value.includes(dom.__value);
|
|
} else {
|
|
// @ts-ignore
|
|
dom.checked = dom.__value === value;
|
|
}
|
|
});
|
|
render_effect(() => {
|
|
return () => {
|
|
const index = binding_group.indexOf(dom);
|
|
if (index !== -1) {
|
|
binding_group.splice(index, 1);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLInputElement} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_checked(dom, get_value, update) {
|
|
dom.addEventListener('change', () => {
|
|
const value = dom.checked;
|
|
update(value);
|
|
});
|
|
// eslint-disable-next-line eqeqeq
|
|
if (get_value() == undefined) {
|
|
update(false);
|
|
}
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
dom.checked = Boolean(value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {'x' | 'y'} type
|
|
* @param {() => number} get_value
|
|
* @param {(value: number) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_window_scroll(type, get_value, update) {
|
|
const is_scrolling_x = type === 'x';
|
|
const target_handler = () => {
|
|
scrolling = true;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(clear, 100);
|
|
const value = window[is_scrolling_x ? 'scrollX' : 'scrollY'];
|
|
update(value);
|
|
};
|
|
addEventListener('scroll', target_handler, {
|
|
passive: true
|
|
});
|
|
let latest_value = 0;
|
|
let scrolling = false;
|
|
|
|
/** @type {ReturnType<typeof setTimeout>} */
|
|
let timeout;
|
|
const clear = () => {
|
|
scrolling = false;
|
|
};
|
|
render_effect(() => {
|
|
latest_value = get_value() || 0;
|
|
if (!scrolling) {
|
|
scrolling = true;
|
|
clearTimeout(timeout);
|
|
if (is_scrolling_x) {
|
|
scrollTo(latest_value, window.scrollY);
|
|
} else {
|
|
scrollTo(window.scrollX, latest_value);
|
|
}
|
|
timeout = setTimeout(clear, 100);
|
|
}
|
|
});
|
|
render_effect(() => {
|
|
return () => {
|
|
removeEventListener('scroll', target_handler);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} property
|
|
* @param {string} event_name
|
|
* @param {'get' | 'set'} type
|
|
* @param {Element} dom
|
|
* @param {() => unknown} get_value
|
|
* @param {(value: unknown) => void} update
|
|
* @returns {void}
|
|
*/
|
|
export function bind_property(property, event_name, type, dom, get_value, update) {
|
|
const target_handler = () => {
|
|
// @ts-ignore
|
|
const value = dom[property];
|
|
update(value);
|
|
};
|
|
dom.addEventListener(event_name, target_handler);
|
|
if (type === 'set') {
|
|
render_effect(() => {
|
|
const value = get_value();
|
|
// @ts-ignore
|
|
dom[property] = value;
|
|
});
|
|
}
|
|
if (type === 'get') {
|
|
// @ts-ignore
|
|
const value = dom[property];
|
|
update(value);
|
|
}
|
|
render_effect(() => {
|
|
// @ts-ignore
|
|
if (dom === document.body || dom === window || dom === document) {
|
|
return () => {
|
|
dom.removeEventListener(event_name, target_handler);
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Makes an `export`ed (non-prop) variable available on the `$$props` object
|
|
* so that consumers can do `bind:x` on the component.
|
|
* @template V
|
|
* @param {Record<string, unknown>} props
|
|
* @param {string} prop
|
|
* @param {V} value
|
|
* @returns {void}
|
|
*/
|
|
export function bind_prop(props, prop, value) {
|
|
const desc = get_descriptor(props, prop);
|
|
|
|
if (desc && desc.set) {
|
|
props[prop] = value;
|
|
render_effect(() => () => {
|
|
props[prop] = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Element} element_or_component
|
|
* @param {(value: unknown, ...parts: unknown[]) => void} update
|
|
* @param {(...parts: unknown[]) => unknown} get_value
|
|
* @param {() => unknown[]} [get_parts] Set if the this binding is used inside an each block,
|
|
* returns all the parts of the each block context that are used in the expression
|
|
* @returns {void}
|
|
*/
|
|
export function bind_this(element_or_component, update, get_value, get_parts) {
|
|
/** @type {unknown[]} */
|
|
let old_parts;
|
|
/** @type {unknown[]} */
|
|
let parts;
|
|
|
|
const e = effect(() => {
|
|
old_parts = parts;
|
|
// We only track changes to the parts, not the value itself to avoid unnecessary reruns.
|
|
parts = get_parts?.() || [];
|
|
|
|
untrack(() => {
|
|
if (element_or_component !== get_value(...parts)) {
|
|
update(element_or_component, ...parts);
|
|
// If this is an effect rerun (cause: each block context changes), then nullfiy the binding at
|
|
// the previous position if it isn't already taken over by a different effect.
|
|
if (old_parts && get_value(...old_parts) === element_or_component) {
|
|
update(null, ...old_parts);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add effect teardown (likely causes: if block became false, each item removed, component unmounted).
|
|
// In these cases we need to nullify the binding only if we detect that the value is still the same.
|
|
// If not, that means that another effect has now taken over the binding.
|
|
push_destroy_fn(e, () => {
|
|
// Defer to the next tick so that all updates can be reconciled first.
|
|
// This solves the case where one variable is shared across multiple this-bindings.
|
|
effect(() => {
|
|
if (get_value(...parts) === element_or_component) {
|
|
update(null, ...parts);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @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 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) {
|
|
// This is the fallback document listener but the event was already handled
|
|
// -> ignore, but set handle_at to document so that we're resetting the event
|
|
// chain in case someone manually dispatches the same event object again.
|
|
// @ts-expect-error
|
|
event.__root = document;
|
|
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() {
|
|
// TODO: ensure correct document?
|
|
return current_target || 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
|
|
* @param {Record<string, unknown>} slot_props
|
|
* @param {null | ((anchor: Comment) => void)} fallback_fn
|
|
*/
|
|
export function slot(anchor_node, slot_fn, slot_props, fallback_fn) {
|
|
hydrate_block_anchor(anchor_node);
|
|
if (slot_fn === undefined) {
|
|
if (fallback_fn !== null) {
|
|
fallback_fn(anchor_node);
|
|
}
|
|
} else {
|
|
slot_fn(anchor_node, slot_props);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {(anchor: Node | null) => void} fn
|
|
* @returns {void}
|
|
*/
|
|
export function head(fn) {
|
|
// The head function may be called after the first hydration pass and ssr comment nodes may still be present,
|
|
// therefore we need to skip that when we detect that we're not in hydration mode.
|
|
let hydration_fragment = null;
|
|
let previous_hydration_fragment = null;
|
|
|
|
if (hydrating) {
|
|
hydration_fragment = get_hydration_fragment(document.head.firstChild);
|
|
previous_hydration_fragment = current_hydration_fragment;
|
|
set_current_hydration_fragment(hydration_fragment);
|
|
}
|
|
|
|
try {
|
|
render_effect(() => {
|
|
let anchor = null;
|
|
|
|
if (!hydrating) {
|
|
anchor = empty();
|
|
document.head.appendChild(anchor);
|
|
}
|
|
|
|
fn(anchor);
|
|
});
|
|
} finally {
|
|
if (hydrating) {
|
|
set_current_hydration_fragment(previous_hydration_fragment);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Element | Text | Comment} anchor
|
|
* @param {boolean} is_html
|
|
* @param {() => Record<string, string>} props
|
|
* @param {(anchor: Element | Text | Comment) => any} component
|
|
* @returns {void}
|
|
*/
|
|
export function cssProps(anchor, is_html, props, component) {
|
|
hydrate_block_anchor(anchor);
|
|
|
|
/** @type {HTMLElement | SVGElement} */
|
|
let tag;
|
|
|
|
/** @type {Text | Comment} */
|
|
let component_anchor;
|
|
if (hydrating) {
|
|
// Hydration: css props element is surrounded by a ssr comment ...
|
|
tag = /** @type {HTMLElement | SVGElement} */ (current_hydration_fragment[0]);
|
|
// ... and the child(ren) of the css props element is also surround by a ssr comment
|
|
component_anchor = /** @type {Comment} */ (tag.firstChild);
|
|
} else {
|
|
if (is_html) {
|
|
tag = document.createElement('div');
|
|
tag.style.display = 'contents';
|
|
} else {
|
|
tag = document.createElementNS(namespace_svg, 'g');
|
|
}
|
|
insert(tag, null, anchor);
|
|
component_anchor = empty();
|
|
tag.appendChild(component_anchor);
|
|
}
|
|
component(component_anchor);
|
|
|
|
/** @type {Record<string, string>} */
|
|
let current_props = {};
|
|
const effect = render_effect(() => {
|
|
const next_props = props();
|
|
for (const key in current_props) {
|
|
if (!(key in next_props)) {
|
|
tag.style.removeProperty(key);
|
|
}
|
|
}
|
|
for (const key in next_props) {
|
|
tag.style.setProperty(key, next_props[key]);
|
|
}
|
|
current_props = next_props;
|
|
});
|
|
push_destroy_fn(effect, () => {
|
|
remove(tag);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} value
|
|
* @returns {string}
|
|
*/
|
|
export function stringify(value) {
|
|
return typeof value === 'string' ? value : value == null ? '' : value + '';
|
|
}
|
|
|
|
/**
|
|
* @param {Element | Text | Comment} dom
|
|
* @param {() => string} get_value
|
|
* @param {boolean} svg
|
|
* @returns {void}
|
|
*/
|
|
export function html(dom, get_value, svg) {
|
|
/** @type {import('./types.js').TemplateNode | import('./types.js').TemplateNode[]} */
|
|
let html_dom;
|
|
/** @type {string} */
|
|
let value;
|
|
const effect = render_effect(() => {
|
|
if (value !== (value = get_value())) {
|
|
if (html_dom) {
|
|
remove(html_dom);
|
|
}
|
|
html_dom = reconcile_html(dom, value, svg);
|
|
}
|
|
});
|
|
push_destroy_fn(effect, () => {
|
|
if (html_dom) {
|
|
remove(html_dom);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @template P
|
|
* @param {HTMLElement} dom
|
|
* @param {() => import('./types.js').TransitionFn<P | undefined>} get_transition_fn
|
|
* @param {(() => P) | null} props
|
|
* @param {any} global
|
|
* @returns {void}
|
|
*/
|
|
export function transition(dom, get_transition_fn, props, global = false) {
|
|
bind_transition(dom, get_transition_fn, props, 'both', global);
|
|
}
|
|
|
|
/**
|
|
* @template P
|
|
* @param {HTMLElement} dom
|
|
* @param {() => import('./types.js').TransitionFn<P | undefined>} get_transition_fn
|
|
* @param {(() => P) | null} props
|
|
* @returns {void}
|
|
*/
|
|
export function animate(dom, get_transition_fn, props) {
|
|
throw new Error('TODO animate');
|
|
}
|
|
|
|
/**
|
|
* @template P
|
|
* @param {HTMLElement} dom
|
|
* @param {() => import('./types.js').TransitionFn<P | undefined>} get_transition_fn
|
|
* @param {(() => P) | null} props
|
|
* @param {any} global
|
|
* @returns {void}
|
|
*/
|
|
function in_fn(dom, get_transition_fn, props, global = false) {
|
|
bind_transition(dom, get_transition_fn, props, 'in', global);
|
|
}
|
|
export { in_fn as in };
|
|
|
|
/**
|
|
* @template P
|
|
* @param {HTMLElement} dom
|
|
* @param {() => import('./types.js').TransitionFn<P | undefined>} get_transition_fn
|
|
* @param {(() => P) | null} props
|
|
* @param {any} global
|
|
* @returns {void}
|
|
*/
|
|
export function out(dom, get_transition_fn, props, global = false) {
|
|
bind_transition(dom, get_transition_fn, props, 'out', global);
|
|
}
|
|
|
|
/**
|
|
* @template P
|
|
* @param {Element} dom
|
|
* @param {(dom: Element, value?: P) => import('./types.js').ActionPayload<P>} action
|
|
* @param {() => P} [value_fn]
|
|
* @returns {void}
|
|
*/
|
|
export function action(dom, action, value_fn) {
|
|
/** @type {undefined | import('./types.js').ActionPayload<P>} */
|
|
let payload = undefined;
|
|
// Action could come from a prop, therefore could be a signal, therefore untrack
|
|
// TODO we could take advantage of this and enable https://github.com/sveltejs/svelte/issues/6942
|
|
effect(() => {
|
|
if (value_fn) {
|
|
const value = value_fn();
|
|
let needs_deep_read = false;
|
|
untrack(() => {
|
|
if (payload === undefined) {
|
|
payload = action(dom, value) || {};
|
|
needs_deep_read = !!payload?.update;
|
|
} else {
|
|
const update = payload.update;
|
|
if (typeof update === 'function') {
|
|
update(value);
|
|
}
|
|
}
|
|
});
|
|
// Action's update method is coarse-grained, i.e. when anything in the passed value changes, update.
|
|
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
|
|
// together with actions and mutation, it wouldn't notice the change without a deep read.
|
|
if (needs_deep_read) {
|
|
deep_read(value);
|
|
}
|
|
} else {
|
|
untrack(() => (payload = action(dom)));
|
|
}
|
|
});
|
|
effect(() => {
|
|
if (payload !== undefined) {
|
|
const destroy = payload.destroy;
|
|
if (typeof destroy === 'function') {
|
|
return () => {
|
|
destroy();
|
|
};
|
|
}
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* 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
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`).
|
|
* Is passed the full `$$props` object and excludes the named props.
|
|
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol> }>}}
|
|
*/
|
|
const rest_props_handler = {
|
|
get(target, key) {
|
|
if (target.exclude.includes(key)) return;
|
|
return target.props[key];
|
|
},
|
|
getOwnPropertyDescriptor(target, key) {
|
|
if (target.exclude.includes(key)) return;
|
|
if (key in target.props) {
|
|
return {
|
|
enumerable: true,
|
|
configurable: true,
|
|
value: target.props[key]
|
|
};
|
|
}
|
|
},
|
|
has(target, key) {
|
|
if (target.exclude.includes(key)) return false;
|
|
return key in target.props;
|
|
},
|
|
ownKeys(target) {
|
|
return Reflect.ownKeys(target.props).filter((key) => !target.exclude.includes(key));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @param {import('./types.js').Signal<Record<string, unknown>> | Record<string, unknown>} props
|
|
* @param {string[]} rest
|
|
* @returns {Record<string, unknown>}
|
|
*/
|
|
export function rest_props(props, rest) {
|
|
return new Proxy({ props, exclude: rest }, rest_props_handler);
|
|
}
|
|
|
|
/**
|
|
* The proxy handler for spread props. Handles the incoming array of props
|
|
* that looks like `() => { dynamic: props }, { static: prop }, ..` and wraps
|
|
* them so that the whole thing is passed to the component as the `$$props` argument.
|
|
* @template {Record<string | symbol, unknown>} T
|
|
* @type {ProxyHandler<{ props: Array<T | (() => T)> }>}}
|
|
*/
|
|
const spread_props_handler = {
|
|
get(target, key) {
|
|
let i = target.props.length;
|
|
while (i--) {
|
|
let p = target.props[i];
|
|
if (is_function(p)) p = p();
|
|
if (typeof p === 'object' && p !== null && key in p) return p[key];
|
|
}
|
|
},
|
|
getOwnPropertyDescriptor(target, key) {
|
|
let i = target.props.length;
|
|
while (i--) {
|
|
let p = target.props[i];
|
|
if (is_function(p)) p = p();
|
|
if (typeof p === 'object' && p !== null && key in p) return get_descriptor(p, key);
|
|
}
|
|
},
|
|
has(target, key) {
|
|
for (let p of target.props) {
|
|
if (is_function(p)) p = p();
|
|
if (key in p) return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
ownKeys(target) {
|
|
/** @type {Array<string | symbol>} */
|
|
const keys = [];
|
|
|
|
for (let p of target.props) {
|
|
if (is_function(p)) p = p();
|
|
for (const key in p) {
|
|
if (!keys.includes(key)) keys.push(key);
|
|
}
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @param {Array<Record<string, unknown> | (() => Record<string, unknown>)>} props
|
|
* @returns {any}
|
|
*/
|
|
export function spread_props(...props) {
|
|
return new Proxy({ props }, spread_props_handler);
|
|
}
|
|
|
|
// TODO 5.0 remove this
|
|
/**
|
|
* @deprecated Use `mount` or `hydrate` instead
|
|
*/
|
|
export function createRoot() {
|
|
throw new Error(
|
|
'`createRoot` has been removed. Use `mount` or `hydrate` instead. See the updated docs for more info: https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component
|
|
*
|
|
* @template {Record<string, any>} Props
|
|
* @template {Record<string, any>} Exports
|
|
* @template {Record<string, any>} Events
|
|
* @param {import('../../main/public.js').ComponentType<import('../../main/public.js').SvelteComponent<Props, Events>>} component
|
|
* @param {{
|
|
* target: Node;
|
|
* props?: Props;
|
|
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
|
|
* context?: Map<any, any>;
|
|
* intro?: boolean;
|
|
* }} options
|
|
* @returns {Exports}
|
|
*/
|
|
export function mount(component, options) {
|
|
init_operations();
|
|
const anchor = empty();
|
|
options.target.appendChild(anchor);
|
|
// Don't flush previous effects to ensure order of outer effects stays consistent
|
|
return flush_sync(() => _mount(component, { ...options, anchor }), false);
|
|
}
|
|
|
|
/**
|
|
* Hydrates a component on the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component
|
|
*
|
|
* @template {Record<string, any>} Props
|
|
* @template {Record<string, any>} Exports
|
|
* @template {Record<string, any>} Events
|
|
* @param {import('../../main/public.js').ComponentType<import('../../main/public.js').SvelteComponent<Props, Events>>} component
|
|
* @param {{
|
|
* target: Node;
|
|
* props?: Props;
|
|
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
|
|
* context?: Map<any, any>;
|
|
* intro?: boolean;
|
|
* recover?: false;
|
|
* }} options
|
|
* @returns {Exports}
|
|
*/
|
|
export function hydrate(component, options) {
|
|
init_operations();
|
|
const container = options.target;
|
|
const first_child = /** @type {ChildNode} */ (container.firstChild);
|
|
// Call with insert_text == true to prevent empty {expressions} resulting in an empty
|
|
// fragment array, resulting in a hydration error down the line
|
|
const hydration_fragment = get_hydration_fragment(first_child, true);
|
|
const previous_hydration_fragment = current_hydration_fragment;
|
|
set_current_hydration_fragment(hydration_fragment);
|
|
|
|
/** @type {null | Text} */
|
|
let anchor = null;
|
|
if (hydration_fragment === null) {
|
|
anchor = empty();
|
|
container.appendChild(anchor);
|
|
}
|
|
|
|
let finished_hydrating = false;
|
|
|
|
try {
|
|
// Don't flush previous effects to ensure order of outer effects stays consistent
|
|
return flush_sync(() => {
|
|
const instance = _mount(component, { ...options, anchor });
|
|
// flush_sync will run this callback and then synchronously run any pending effects,
|
|
// which don't belong to the hydration phase anymore - therefore reset it here
|
|
set_current_hydration_fragment(null);
|
|
finished_hydrating = true;
|
|
return instance;
|
|
}, false);
|
|
} catch (error) {
|
|
if (!finished_hydrating && options.recover !== false && hydration_fragment !== null) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
'ERR_SVELTE_HYDRATION_MISMATCH' +
|
|
(DEV
|
|
? ': Hydration failed because the initial UI does not match what was rendered on the server.'
|
|
: ''),
|
|
error
|
|
);
|
|
remove(hydration_fragment);
|
|
first_child.remove();
|
|
hydration_fragment.at(-1)?.nextSibling?.remove();
|
|
set_current_hydration_fragment(null);
|
|
return mount(component, options);
|
|
} else {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
set_current_hydration_fragment(previous_hydration_fragment);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template {Record<string, any>} Props
|
|
* @template {Record<string, any>} Exports
|
|
* @template {Record<string, any>} Events
|
|
* @param {import('../../main/public.js').ComponentType<import('../../main/public.js').SvelteComponent<Props, Events>>} Component
|
|
* @param {{
|
|
* target: Node;
|
|
* anchor: null | Text;
|
|
* props?: Props;
|
|
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
|
|
* context?: Map<any, any>;
|
|
* intro?: boolean;
|
|
* recover?: false;
|
|
* }} options
|
|
* @returns {Exports}
|
|
*/
|
|
function _mount(Component, options) {
|
|
const registered_events = new Set();
|
|
const container = options.target;
|
|
|
|
run_transitions = options.intro ?? false;
|
|
|
|
/** @type {Exports} */
|
|
// @ts-expect-error will be defined because the render effect runs synchronously
|
|
let component = undefined;
|
|
|
|
const effect = render_effect(() => {
|
|
if (options.context) {
|
|
push({});
|
|
/** @type {import('../client/types.js').ComponentContext} */ (current_component_context).c =
|
|
options.context;
|
|
}
|
|
if (!options.props) {
|
|
options.props = /** @type {Props} */ ({});
|
|
}
|
|
if (options.events) {
|
|
// We can't spread the object or else we'd lose the state proxy stuff, if it is one
|
|
/** @type {any} */ (options.props).$$events = options.events;
|
|
}
|
|
component =
|
|
// @ts-expect-error the public typings are not what the actual function looks like
|
|
Component(options.anchor, options.props) || {};
|
|
if (options.context) {
|
|
pop();
|
|
}
|
|
}, true);
|
|
|
|
const bound_event_listener = handle_event_propagation.bind(null, container);
|
|
const bound_document_event_listener = handle_event_propagation.bind(null, document);
|
|
|
|
run_transitions = true;
|
|
|
|
/** @param {Array<string>} events */
|
|
const event_handle = (events) => {
|
|
for (let i = 0; i < events.length; i++) {
|
|
const event_name = events[i];
|
|
if (!registered_events.has(event_name)) {
|
|
registered_events.add(event_name);
|
|
// Add the event listener to both the container and the document.
|
|
// The container listener ensures we catch events from within in case
|
|
// the outer content stops propagation of the event.
|
|
container.addEventListener(
|
|
event_name,
|
|
bound_event_listener,
|
|
PassiveDelegatedEvents.includes(event_name)
|
|
? {
|
|
passive: true
|
|
}
|
|
: undefined
|
|
);
|
|
// The document listener ensures we catch events that originate from elements that were
|
|
// manually moved outside of the container (e.g. via manual portals).
|
|
document.addEventListener(
|
|
event_name,
|
|
bound_document_event_listener,
|
|
PassiveDelegatedEvents.includes(event_name)
|
|
? {
|
|
passive: true
|
|
}
|
|
: undefined
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
event_handle(array_from(all_registered_events));
|
|
root_event_handles.add(event_handle);
|
|
|
|
mounted_components.set(component, () => {
|
|
for (const event_name of registered_events) {
|
|
container.removeEventListener(event_name, bound_event_listener);
|
|
}
|
|
|
|
root_event_handles.delete(event_handle);
|
|
|
|
destroy_effect(effect);
|
|
});
|
|
|
|
return component;
|
|
}
|
|
|
|
/**
|
|
* References of the components that were mounted or hydrated.
|
|
* Uses a `WeakMap` to avoid memory leaks.
|
|
*/
|
|
let mounted_components = new WeakMap();
|
|
|
|
/**
|
|
* Unmounts a component that was previously mounted using `mount` or `hydrate`.
|
|
* @param {Record<string, any>} component
|
|
*/
|
|
export function unmount(component) {
|
|
const fn = mounted_components.get(component);
|
|
if (DEV && !fn) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('Tried to unmount a component that was not mounted.');
|
|
}
|
|
fn?.();
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, any>} props
|
|
* @returns {Record<string, any>}
|
|
*/
|
|
export function sanitize_slots(props) {
|
|
const sanitized = { ...props.$$slots };
|
|
if (props.children) sanitized.default = props.children;
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* @param {Node} target
|
|
* @param {string} style_sheet_id
|
|
* @param {string} styles
|
|
*/
|
|
export async function append_styles(target, style_sheet_id, styles) {
|
|
// Wait a tick so that the template is added to the dom, else getRootNode() will yield wrong results
|
|
// If it turns out that this results in noticeable flickering, we need to do something like doing the
|
|
// append outside and adding code in mount that appends all stylesheets (similar to how we do it with event delegation)
|
|
await Promise.resolve();
|
|
const append_styles_to = get_root_for_style(target);
|
|
if (!append_styles_to.getElementById(style_sheet_id)) {
|
|
const style = create_element('style');
|
|
style.id = style_sheet_id;
|
|
style.textContent = styles;
|
|
append_child(/** @type {Document} */ (append_styles_to).head || append_styles_to, style);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Node} node
|
|
*/
|
|
function get_root_for_style(node) {
|
|
if (!node) return document;
|
|
const root = node.getRootNode ? node.getRootNode() : node.ownerDocument;
|
|
if (root && /** @type {ShadowRoot} */ (root).host) {
|
|
return /** @type {ShadowRoot} */ (root);
|
|
}
|
|
return /** @type {Document} */ (node.ownerDocument);
|
|
}
|
|
|
|
/**
|
|
* This function is responsible for synchronizing a possibly bound prop with the inner component state.
|
|
* It is used whenever the compiler sees that the component writes to the prop, or when it has a default prop_value.
|
|
* @template V
|
|
* @param {Record<string, unknown>} props
|
|
* @param {string} key
|
|
* @param {number} flags
|
|
* @param {V | (() => V)} [initial]
|
|
* @returns {(() => V | ((arg: V) => V) | ((arg: V, mutation: boolean) => V))}
|
|
*/
|
|
export function prop(props, key, flags, initial) {
|
|
var immutable = (flags & PROPS_IS_IMMUTABLE) !== 0;
|
|
var runes = (flags & PROPS_IS_RUNES) !== 0;
|
|
var prop_value = /** @type {V} */ (props[key]);
|
|
var setter = get_descriptor(props, key)?.set;
|
|
|
|
if (prop_value === undefined && initial !== undefined) {
|
|
if (setter && runes) {
|
|
// TODO consolidate all these random runtime errors
|
|
throw new Error(
|
|
'ERR_SVELTE_BINDING_FALLBACK' +
|
|
(DEV
|
|
? `: Cannot pass undefined to bind:${key} because the property contains a fallback value. Pass a different value than undefined to ${key}.`
|
|
: '')
|
|
);
|
|
}
|
|
|
|
// @ts-expect-error would need a cumbersome method overload to type this
|
|
if ((flags & PROPS_IS_LAZY_INITIAL) !== 0) initial = initial();
|
|
|
|
prop_value = /** @type {V} */ (initial);
|
|
|
|
if (setter) setter(prop_value);
|
|
}
|
|
|
|
var getter = () => {
|
|
var value = /** @type {V} */ (props[key]);
|
|
if (value !== undefined) initial = undefined;
|
|
return value === undefined ? /** @type {V} */ (initial) : value;
|
|
};
|
|
|
|
// easy mode — prop is never written to
|
|
if ((flags & PROPS_IS_UPDATED) === 0) {
|
|
return getter;
|
|
}
|
|
|
|
// intermediate mode — prop is written to, but the parent component had
|
|
// `bind:foo` which means we can just call `$$props.foo = value` directly
|
|
if (setter) {
|
|
return function (/** @type {V} */ value) {
|
|
if (arguments.length === 1) {
|
|
/** @type {Function} */ (setter)(value);
|
|
return value;
|
|
} else {
|
|
return getter();
|
|
}
|
|
};
|
|
}
|
|
|
|
// hard mode. this is where it gets ugly — the value in the child should
|
|
// synchronize with the parent, but it should also be possible to temporarily
|
|
// set the value to something else locally.
|
|
var from_child = false;
|
|
var was_from_child = false;
|
|
|
|
// The derived returns the current value. The underlying mutable
|
|
// source is written to from various places to persist this value.
|
|
var inner_current_value = mutable_source(prop_value);
|
|
var current_value = derived(() => {
|
|
var parent_value = getter();
|
|
var child_value = get(inner_current_value);
|
|
|
|
if (from_child) {
|
|
from_child = false;
|
|
was_from_child = true;
|
|
return child_value;
|
|
}
|
|
|
|
was_from_child = false;
|
|
return (inner_current_value.v = parent_value);
|
|
});
|
|
|
|
if (!immutable) current_value.e = safe_equal;
|
|
|
|
return function (/** @type {V} */ value, mutation = false) {
|
|
var current = get(current_value);
|
|
|
|
// legacy nonsense — need to ensure the source is invalidated when necessary
|
|
// also needed for when handling inspect logic so we can inspect the correct source signal
|
|
if (is_signals_recorded || (DEV && inspect_fn)) {
|
|
// set this so that we don't reset to the parent value if `d`
|
|
// is invalidated because of `invalidate_inner_signals` (rather
|
|
// than because the parent or child value changed)
|
|
from_child = was_from_child;
|
|
// invoke getters so that signals are picked up by `invalidate_inner_signals`
|
|
getter();
|
|
get(inner_current_value);
|
|
}
|
|
|
|
if (arguments.length > 0) {
|
|
if (mutation || (immutable ? value !== current : safe_not_equal(value, current))) {
|
|
from_child = true;
|
|
set(inner_current_value, mutation ? current : value);
|
|
get(current_value); // force a synchronisation immediately
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
return current;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Legacy-mode only: Call `onMount` callbacks and set up `beforeUpdate`/`afterUpdate` effects
|
|
*/
|
|
export function init() {
|
|
const context = /** @type {import('./types.js').ComponentContext} */ (current_component_context);
|
|
const callbacks = context.u;
|
|
|
|
if (!callbacks) return;
|
|
|
|
// beforeUpdate
|
|
pre_effect(() => {
|
|
observe_all(context);
|
|
callbacks.b.forEach(run);
|
|
});
|
|
|
|
// onMount (must run before afterUpdate)
|
|
user_effect(() => {
|
|
const fns = untrack(() => callbacks.m.map(run));
|
|
return () => {
|
|
for (const fn of fns) {
|
|
if (typeof fn === 'function') {
|
|
fn();
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
// afterUpdate
|
|
user_effect(() => {
|
|
observe_all(context);
|
|
callbacks.a.forEach(run);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Invoke the getter of all signals associated with a component
|
|
* so they can be registered to the effect this function is called in.
|
|
* @param {import('./types.js').ComponentContext} context
|
|
*/
|
|
function observe_all(context) {
|
|
if (context.d) {
|
|
for (const signal of context.d) get(signal);
|
|
}
|
|
|
|
deep_read(context.s);
|
|
}
|
|
|
|
/**
|
|
* Under some circumstances, imports may be reactive in legacy mode. In that case,
|
|
* they should be using `reactive_import` as part of the transformation
|
|
* @param {() => any} fn
|
|
*/
|
|
export function reactive_import(fn) {
|
|
const s = source(0);
|
|
return function () {
|
|
if (arguments.length === 1) {
|
|
set(s, get(s) + 1);
|
|
return arguments[0];
|
|
} else {
|
|
get(s);
|
|
return fn();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @this {any}
|
|
* @param {Record<string, unknown>} $$props
|
|
* @param {Event} event
|
|
* @returns {void}
|
|
*/
|
|
export function bubble_event($$props, event) {
|
|
var events = /** @type {Record<string, Function[] | Function>} */ ($$props.$$events)?.[
|
|
event.type
|
|
];
|
|
var callbacks = is_array(events) ? events.slice() : events == null ? [] : [events];
|
|
for (var fn of callbacks) {
|
|
// Preserve "this" context
|
|
fn.call(this, event);
|
|
}
|
|
}
|