diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index df96f4899b..032b6c3e2b 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -69,6 +69,7 @@ export const STALE_REACTION = new (class StaleReactionError extends Error { message = 'The reaction that called `getAbortSignal()` was re-run or destroyed'; })(); +// TODO: DOM access export const IS_XHTML = // We gotta write it like this because after downleveling the pure comment may end up in the wrong location !!globalThis.document?.contentType && @@ -77,3 +78,10 @@ export const ELEMENT_NODE = 1; export const TEXT_NODE = 3; export const COMMENT_NODE = 8; export const DOCUMENT_FRAGMENT_NODE = 11; + +export const CUSTOM_RENDERER_NODE_TYPE_MAP = /** @type {const} */ ({ + fragment: DOCUMENT_FRAGMENT_NODE, + element: ELEMENT_NODE, + text: TEXT_NODE, + comment: COMMENT_NODE +}); diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 4036aa2d61..52c68291ca 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -5,9 +5,15 @@ import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; import { active_effect } from '../runtime.js'; import { async_mode_flag } from '../../flags/index.js'; -import { TEXT_NODE, REACTION_RAN } from '#client/constants'; +import { + TEXT_NODE, + REACTION_RAN, + CUSTOM_RENDERER_NODE_TYPE_MAP, + COMMENT_NODE +} from '#client/constants'; import { eager_block_effects } from '../reactivity/batch.js'; import { NAMESPACE_HTML } from '../../../constants.js'; +import { renderer } from '../custom-renderer/state.js'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -78,6 +84,7 @@ export function init_operations() { * @returns {Text} */ export function create_text(value = '') { + if (renderer) return /** @type {Text} */ (renderer.createTextNode(value)); return document.createTextNode(value); } @@ -87,6 +94,7 @@ export function create_text(value = '') { */ /*@__NO_SIDE_EFFECTS__*/ export function get_first_child(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getFirstChild(node)); return /** @type {TemplateNode | null} */ (first_child_getter.call(node)); } @@ -96,6 +104,7 @@ export function get_first_child(node) { */ /*@__NO_SIDE_EFFECTS__*/ export function get_next_sibling(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getNextSibling(node)); return /** @type {TemplateNode | null} */ (next_sibling_getter.call(node)); } @@ -115,10 +124,10 @@ export function child(node, is_text) { // Child can be null if we have an element with a single child, like `

{text}

`, where `text` is empty if (child === null) { - child = hydrate_node.appendChild(create_text()); - } else if (is_text && child.nodeType !== TEXT_NODE) { + child = /** @type {TemplateNode} */ (append_child(hydrate_node, create_text())); + } else if (is_text && node_type(child) !== TEXT_NODE) { var text = create_text(); - child?.before(text); + insert_before(child, text); set_hydrate_node(text); return text; } @@ -142,7 +151,8 @@ export function first_child(node, is_text = false) { var first = get_first_child(node); // TODO prevent user comments with the empty string when preserveComments is true - if (first instanceof Comment && first.data === '') return get_next_sibling(first); + // TODO RENDERER: can we find another way to not have an `isComment` on the renderer? + if (is_comment(first) && get_node_value(first) === '') return get_next_sibling(first); return first; } @@ -150,10 +160,10 @@ export function first_child(node, is_text = false) { if (is_text) { // if an {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (hydrate_node?.nodeType !== TEXT_NODE) { + if (node_type(hydrate_node) !== TEXT_NODE) { var text = create_text(); - hydrate_node?.before(text); + if (hydrate_node) insert_before(hydrate_node, text); set_hydrate_node(text); return text; } @@ -187,15 +197,15 @@ export function sibling(node, count = 1, is_text = false) { if (is_text) { // if a sibling {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (next_sibling?.nodeType !== TEXT_NODE) { + if (node_type(next_sibling) !== TEXT_NODE) { var text = create_text(); // If the next sibling is `null` and we're handling text then it's because // the SSR content was empty for the text, so we need to generate a new text // node and insert it after the last sibling if (next_sibling === null) { - last_sibling?.after(text); + if (last_sibling) insert_after(last_sibling, text); } else { - next_sibling.before(text); + insert_before(next_sibling, text); } set_hydrate_node(text); return text; @@ -214,6 +224,15 @@ export function sibling(node, count = 1, is_text = false) { * @returns {void} */ export function clear_text_content(node) { + if (renderer) { + var child = renderer.getFirstChild(node); + while (child !== null) { + var next = renderer.getNextSibling(child); + renderer.remove(child); + child = next; + } + return; + } node.textContent = ''; } @@ -239,6 +258,10 @@ export function should_defer_append() { * @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ export function create_element(tag, namespace, is) { + if (renderer) + return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ ( + renderer.createElement(tag) + ); let options = is ? { is } : undefined; return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ ( document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options) @@ -246,6 +269,7 @@ export function create_element(tag, namespace, is) { } export function create_fragment() { + if (renderer) return /** @type {DocumentFragment} */ (renderer.createFragment()); return document.createDocumentFragment(); } @@ -254,6 +278,7 @@ export function create_fragment() { * @returns */ export function create_comment(data = '') { + if (renderer) return /** @type {Comment} */ (renderer.createComment(data)); return document.createComment(data); } @@ -264,6 +289,10 @@ export function create_comment(data = '') { * @returns */ export function set_attribute(element, key, value = '') { + if (renderer) { + renderer.setAttribute(element, key, value); + return; + } if (key.startsWith('xlink:')) { element.setAttributeNS('http://www.w3.org/1999/xlink', key, value); return; @@ -277,13 +306,16 @@ export function set_attribute(element, key, value = '') { * @param {Text} text */ export function merge_text_nodes(text) { + // if we have a renderer we will not hydrate so we can skip this + if (renderer) return; + if (/** @type {string} */ (text.nodeValue).length < 65536) { return; } let next = text.nextSibling; - while (next !== null && next.nodeType === TEXT_NODE) { + while (next !== null && node_type(next) === TEXT_NODE) { next.remove(); /** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue); @@ -291,3 +323,438 @@ export function merge_text_nodes(text) { next = text.nextSibling; } } + +/** + * @param {TemplateNode | null} node + * @returns {node is Comment} + */ +function is_comment(node) { + if (renderer) return !!node && node_type(node) === COMMENT_NODE; + return node instanceof Comment; +} + +/** + * @param {Node | null | undefined} node + */ +export function node_type(node) { + if (node == null) return undefined; + if (renderer) { + const type = renderer.nodeType(node); + return CUSTOM_RENDERER_NODE_TYPE_MAP[type]; + } + return node?.nodeType; +} + +/** + * @param {Node | null | undefined} node + */ +export function node_name(node) { + if (node == null) return undefined; + if (renderer) { + return ''; + } + return node?.nodeName; +} + +/** + * @param {Node} node + * @returns {TemplateNode | null} + */ +export function get_last_child(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getLastChild(node)); + return /** @type {TemplateNode | null} */ (node.lastChild); +} + +/** + * @param {Node} node + * @returns {TemplateNode | null} + */ +export function get_parent_node(node) { + if (renderer) return /** @type {TemplateNode | null} */ (renderer.getParent(node)); + return /** @type {TemplateNode | null} */ (node.parentNode); +} + +/** + * @param {Node} parent + * @param {Node} child + * @returns {Node} + */ +export function append_child(parent, child) { + if (renderer) { + renderer.insert(parent, child, null); + return child; + } + return parent.appendChild(child); +} + +/** + * Insert `new_node` before `ref_node` (equivalent to `ref_node.before(new_node)`) + * @param {ChildNode} ref_node + * @param {Node} new_node + */ +export function insert_before(ref_node, new_node) { + if (renderer) { + var parent = renderer.getParent(ref_node); + renderer.insert(parent, new_node, ref_node); + return; + } + ref_node.before(new_node); +} + +/** + * Insert `new_node` after `ref_node` (equivalent to `ref_node.after(new_node)`) + * @param {ChildNode} ref_node + * @param {Node} new_node + */ +export function insert_after(ref_node, new_node) { + if (renderer) { + var parent = renderer.getParent(ref_node); + var next = renderer.getNextSibling(ref_node); + renderer.insert(parent, new_node, next); + return; + } + ref_node.after(new_node); +} + +/** + * @param {ChildNode} node + */ +export function remove_node(node) { + if (renderer) { + renderer.remove(node); + return; + } + node.remove(); +} + +/** + * @param {Node} parent + * @param {ChildNode} child + * @returns {ChildNode} + */ +export function remove_child(parent, child) { + if (renderer) { + renderer.remove(child); + return child; + } + return /** @type {ChildNode} */ (parent.removeChild(child)); +} + +/** + * @param {ChildNode} old_node + * @param {Node} new_node + */ +export function replace_with(old_node, new_node) { + if (renderer) { + var parent = renderer.getParent(old_node); + renderer.insert(parent, new_node, old_node); + renderer.remove(old_node); + return; + } + old_node.replaceWith(new_node); +} + +/** + * @param {Node} node + * @param {string} value + */ +export function set_text_content(node, value) { + if (renderer) { + renderer.setText(node, value); + return; + } + node.textContent = value; +} + +/** + * @param {Node} node + * @param {string} value + */ +export function set_node_value(node, value) { + if (renderer) { + renderer.setText(node, value); + return; + } + node.nodeValue = value; +} + +// --- Helpers for style attribute string manipulation (custom renderer) --- + +/** + * @param {string} style_string + * @param {string} property + * @param {string} value + * @param {string} [priority] + * @returns {string} + */ +function set_style_property_in_string(style_string, property, value, priority) { + var declaration = property + ': ' + value + (priority ? ' !' + priority : ''); + var parts = style_string.split(';'); + var found = false; + + for (var i = 0; i < parts.length; i++) { + var colon_index = parts[i].indexOf(':'); + if (colon_index !== -1 && parts[i].substring(0, colon_index).trim() === property) { + parts[i] = ' ' + declaration; + found = true; + break; + } + } + + if (!found) { + parts.push(' ' + declaration); + } + + return parts + .map((p) => p.trim()) + .filter(Boolean) + .join('; '); +} + +/** + * @param {string} style_string + * @param {string} property + * @returns {string} + */ +function remove_style_property_in_string(style_string, property) { + return style_string + .split(';') + .filter((part) => { + var colon_index = part.indexOf(':'); + if (colon_index === -1) return false; + return part.substring(0, colon_index).trim() !== property; + }) + .map((p) => p.trim()) + .filter(Boolean) + .join('; '); +} + +/** + * @param {Node} node + * @returns {string | null} + */ +export function get_node_value(node) { + if (renderer) return renderer.getNodeValue(node); + return node.nodeValue; +} + +/** + * @param {Element} element + * @param {string} name + * @returns {string | null} + */ +export function get_attribute(element, name) { + if (renderer) return renderer.getAttribute(element, name); + return element.getAttribute(name); +} + +/** + * @param {Element} element + * @param {string} name + */ +export function remove_attribute(element, name) { + if (renderer) { + renderer.removeAttribute(element, name); + return; + } + element.removeAttribute(name); +} + +/** + * @param {Element} element + * @param {string} name + * @returns {boolean} + */ +export function has_attribute(element, name) { + // TODO RENDERER: could be worth removing and just using get? + + if (renderer) return renderer.hasAttribute(element, name); + return element.hasAttribute(name); +} + +/** + * @param {Element} element + * @param {string} value + */ +export function set_inner_html(element, value) { + if (renderer) { + throw new Error('setInnerHTML is not supported with custom renderers'); + } + element.innerHTML = value; +} + +/** + * @param {Node} node + * @param {boolean} deep + * @returns {Node} + */ +export function clone_node(node, deep) { + if (renderer) return renderer.cloneNode(node, deep); + return node.cloneNode(deep); +} + +/** + * @param {Node} node + * @param {boolean} deep + * @returns {Node} + */ +export function import_node(node, deep) { + if (renderer) return renderer.cloneNode(node, deep); + return document.importNode(node, deep); +} + +/** + * @param {EventTarget} target + * @param {string} type + * @param {EventListenerOrEventListenerObject} handler + * @param {boolean | AddEventListenerOptions} [options] + */ +export function add_event_listener(target, type, handler, options) { + if (renderer) { + renderer.addEventListener(target, type, handler, options); + return; + } + target.addEventListener(type, handler, options); +} + +/** + * @param {EventTarget} target + * @param {string} type + * @param {EventListenerOrEventListenerObject} handler + * @param {boolean | EventListenerOptions} [options] + */ +export function remove_event_listener(target, type, handler, options) { + if (renderer) { + renderer.removeEventListener(target, type, handler, options); + return; + } + target.removeEventListener(type, handler, options); +} + +/** + * @param {EventTarget} target + * @param {Event} event + * @returns {boolean} + */ +export function dispatch_event(target, event) { + if (renderer) { + // is only used in SSR which is not a thing for custom renderers + throw new Error('dispatchEvent is not supported with custom renderers'); + } + return target.dispatchEvent(event); +} + +/** + * @param {HTMLElement} element + * @param {string} property + * @param {string} value + * @param {string} [priority] + */ +export function style_set_property(element, property, value, priority) { + if (renderer) { + var style = renderer.getAttribute(element, 'style') || ''; + var updated = set_style_property_in_string(style, property, value, priority); + renderer.setAttribute(element, 'style', updated); + return; + } + element.style.setProperty(property, value, priority); +} + +/** + * @param {HTMLElement} element + * @param {string} property + */ +export function style_remove_property(element, property) { + if (renderer) { + var style = renderer.getAttribute(element, 'style') || ''; + var updated = remove_style_property_in_string(style, property); + renderer.setAttribute(element, 'style', updated); + return; + } + element.style.removeProperty(property); +} + +/** + * @param {HTMLElement} element + * @param {string} value + */ +export function set_css_text(element, value) { + if (renderer) { + renderer.setAttribute(element, 'style', value); + return; + } + element.style.cssText = value; +} + +/** + * @param {Element} element + * @param {string} name + * @param {boolean} force + */ +export function class_list_toggle(element, name, force) { + if (renderer) { + const classes = element.getAttribute('class')?.split(/\s+/) ?? []; + const has_class = classes.includes(name); + if (force === has_class) { + return; + } + if (force) { + classes.push(name); + } else { + const index = classes.indexOf(name); + if (index !== -1) { + classes.splice(index, 1); + } + } + element.setAttribute('class', classes.join(' ')); + return; + } + element.classList.toggle(name, force); +} + +/** + * @param {Element} element + * @param {string} selector + */ +export function query_selector(element, selector) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('querySelector is not supported with custom renderers'); + } + return element.querySelector(selector); +} + +/** + * @param {Element | DocumentFragment} element + * @param {string} selector + */ +export function query_selector_all(element, selector) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('querySelectorAll is not supported with custom renderers'); + } + return element.querySelectorAll(selector); +} + +/** + * @param {Node} node + * @returns {Node} + */ +export function get_root_node(node) { + if (renderer) { + // TODO RENDERER: just used with css inject which doesn't make sense with custom renderers + throw new Error('getRootNode is not supported with custom renderers'); + } + return node.getRootNode(); +} + +/** + * @param {HTMLElement} element + */ +export function focus(element) { + if (renderer) { + // doesn't make sense with custom renderers + throw new Error('focus is not supported with custom renderers'); + } + element.focus(); +}