UNPKG

svelte

Version:

Cybernetically enhanced web apps

731 lines (628 loc) 23.1 kB
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { SourceLocation } from '#shared' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */ /** @import { Scope } from '../../../scope' */ import { cannot_be_set_statically, is_boolean_attribute, is_dom_property, is_load_error_element, is_void } from '../../../../../utils.js'; import { escape_html } from '../../../../../escaping.js'; import { dev, is_ignored, locator } from '../../../../state.js'; import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js'; import * as b from '../../../../utils/builders.js'; import { is_custom_element_node } from '../../../nodes.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; import { build_getter } from '../utils.js'; import { get_attribute_name, build_attribute_value, build_set_attributes, build_set_class, build_set_style } from './shared/element.js'; import { process_children } from './shared/fragment.js'; import { build_render_statement, build_template_chunk, build_update_assignment, get_expression_id, memoize_expression } from './shared/utils.js'; import { visit_event_attribute } from './shared/events.js'; /** * @param {AST.RegularElement} node * @param {ComponentContext} context */ export function RegularElement(node, context) { /** @type {SourceLocation} */ let location = [-1, -1]; if (dev) { const loc = locator(node.start); if (loc) { location[0] = loc.line; location[1] = loc.column; context.state.locations.push(location); } } if (node.name === 'noscript') { context.state.template.push('<noscript></noscript>'); return; } const is_custom_element = is_custom_element_node(node); if (node.name === 'video' || is_custom_element) { // cloneNode is faster, but it does not instantiate the underlying class of the // custom element until the template is connected to the dom, which would // cause problems when setting properties on the custom element. // Therefore we need to use importNode instead, which doesn't have this caveat. // Additionally, Webkit browsers need importNode for video elements for autoplay // to work correctly. context.state.metadata.context.template_needs_import_node = true; } if (node.name === 'script') { context.state.metadata.context.template_contains_script_tag = true; } context.state.template.push(`<${node.name}`); /** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ const attributes = []; /** @type {AST.ClassDirective[]} */ const class_directives = []; /** @type {AST.StyleDirective[]} */ const style_directives = []; /** @type {Array<AST.AnimateDirective | AST.BindDirective | AST.OnDirective | AST.TransitionDirective | AST.UseDirective>} */ const other_directives = []; /** @type {ExpressionStatement[]} */ const lets = []; /** @type {Map<string, AST.Attribute>} */ const lookup = new Map(); /** @type {Map<string, AST.BindDirective>} */ const bindings = new Map(); let has_spread = node.metadata.has_spread; let has_use = false; for (const attribute of node.attributes) { switch (attribute.type) { case 'AnimateDirective': other_directives.push(attribute); break; case 'Attribute': // `is` attributes need to be part of the template, otherwise they break if (attribute.name === 'is' && context.state.metadata.namespace === 'html') { const { value } = build_attribute_value(attribute.value, context); if (value.type === 'Literal' && typeof value.value === 'string') { context.state.template.push(` is="${escape_html(value.value, true)}"`); continue; } } attributes.push(attribute); lookup.set(attribute.name, attribute); break; case 'BindDirective': bindings.set(attribute.name, attribute); other_directives.push(attribute); break; case 'ClassDirective': class_directives.push(attribute); break; case 'LetDirective': // visit let directives before everything else, to set state lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); break; case 'OnDirective': other_directives.push(attribute); break; case 'SpreadAttribute': attributes.push(attribute); break; case 'StyleDirective': style_directives.push(attribute); break; case 'TransitionDirective': other_directives.push(attribute); break; case 'UseDirective': has_use = true; other_directives.push(attribute); break; } } /** @type {typeof state} */ const element_state = { ...context.state, init: [], after_update: [] }; for (const attribute of other_directives) { if (attribute.type === 'OnDirective') { const handler = /** @type {Expression} */ (context.visit(attribute)); if (has_use) { element_state.init.push(b.stmt(b.call('$.effect', b.thunk(handler)))); } else { element_state.after_update.push(b.stmt(handler)); } } else { context.visit(attribute, element_state); } } if (node.name === 'input') { const has_value_attribute = attributes.some( (attribute) => attribute.type === 'Attribute' && (attribute.name === 'value' || attribute.name === 'checked') && !is_text_attribute(attribute) ); const has_default_value_attribute = attributes.some( (attribute) => attribute.type === 'Attribute' && (attribute.name === 'defaultValue' || attribute.name === 'defaultChecked') ); if ( !has_default_value_attribute && (has_spread || bindings.has('value') || bindings.has('checked') || bindings.has('group') || (!bindings.has('group') && has_value_attribute)) ) { context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node))); } } if (node.name === 'textarea') { const attribute = lookup.get('value') ?? lookup.get('checked'); const needs_content_reset = attribute && !is_text_attribute(attribute); if (has_spread || bindings.has('value') || needs_content_reset) { context.state.init.push(b.stmt(b.call('$.remove_textarea_child', context.state.node))); } } if (node.name === 'select' && bindings.has('value')) { setup_select_synchronization(/** @type {AST.BindDirective} */ (bindings.get('value')), context); } // Let bindings first, they can be used on attributes context.state.init.push(...lets); const node_id = context.state.node; if (has_spread) { const attributes_id = b.id(context.state.scope.generate('attributes')); build_set_attributes( attributes, class_directives, style_directives, context, node, node_id, attributes_id ); // If value binding exists, that one takes care of calling $.init_select if (node.name === 'select' && !bindings.has('value')) { context.state.init.push( b.stmt(b.call('$.init_select', node_id, b.thunk(b.member(attributes_id, 'value')))) ); context.state.update.push( b.if( b.binary('in', b.literal('value'), attributes_id), b.block([ // This ensures a one-way street to the DOM in case it's <select {value}> // and not <select bind:value>. We need it in addition to $.init_select // because the select value is not reflected as an attribute, so the // mutation observer wouldn't notice. b.stmt(b.call('$.select_option', node_id, b.member(attributes_id, 'value'))) ]) ) ); } } else { /** If true, needs `__value` for inputs */ const needs_special_value_handling = node.name === 'option' || node.name === 'select' || bindings.has('group') || bindings.has('checked'); for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) { if (is_event_attribute(attribute)) { visit_event_attribute(attribute, context); continue; } if (needs_special_value_handling && attribute.name === 'value') { build_element_special_value_attribute(node.name, node_id, attribute, context); continue; } const name = get_attribute_name(node, attribute); if ( !is_custom_element && !cannot_be_set_statically(attribute.name) && (attribute.value === true || is_text_attribute(attribute)) && (name !== 'class' || class_directives.length === 0) && (name !== 'style' || style_directives.length === 0) ) { let value = is_text_attribute(attribute) ? attribute.value[0].data : true; if (name === 'class' && node.metadata.scoped && context.state.analysis.css.hash) { if (value === true || value === '') { value = context.state.analysis.css.hash; } else { value += ' ' + context.state.analysis.css.hash; } } if (name !== 'class' || value) { context.state.template.push( ` ${attribute.name}${ is_boolean_attribute(name) && value === true ? '' : `="${value === true ? '' : escape_html(value, true)}"` }` ); } } else if (name === 'autofocus') { let { value } = build_attribute_value(attribute.value, context); context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value))); } else if (name === 'class') { const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg'; build_set_class(node, node_id, attribute, class_directives, context, is_html); } else if (name === 'style') { build_set_style(node_id, attribute, style_directives, context); } else if (is_custom_element) { build_custom_element_attribute_update_assignment(node_id, attribute, context); } else { const { value, has_state } = build_attribute_value( attribute.value, context, (value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value) ); const update = build_element_attribute_update(node, node_id, name, value, attributes); (has_state ? context.state.update : context.state.init).push(b.stmt(update)); } } } if ( is_load_error_element(node.name) && (has_spread || has_use || lookup.has('onload') || lookup.has('onerror')) ) { context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id))); } context.state.template.push('>'); const metadata = { ...context.state.metadata, namespace: determine_namespace_for_children(node, context.state.metadata.namespace) }; if (bindings.has('innerHTML') || bindings.has('innerText') || bindings.has('textContent')) { const contenteditable = lookup.get('contenteditable'); if ( contenteditable && (contenteditable.value === true || (is_text_attribute(contenteditable) && contenteditable.value[0].data === 'true')) ) { metadata.bound_contenteditable = true; } } /** @type {ComponentClientTransformState} */ const state = { ...context.state, metadata, locations: [], scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), preserve_whitespace: context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea' }; const { hoisted, trimmed } = clean_nodes( node, node.fragment.nodes, context.path, state.metadata.namespace, state, node.name === 'script' || state.preserve_whitespace, state.options.preserveComments ); /** @type {typeof state} */ const child_state = { ...state, init: [], update: [], after_update: [] }; for (const node of hoisted) { context.visit(node, child_state); } // special case — if an element that only contains text, we don't need // to descend into it if the text is non-reactive // in the rare case that we have static text that can't be inlined // (e.g. `<span>{location}</span>`), set `textContent` programmatically const use_text_content = trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag') && trimmed.every((node) => node.type === 'Text' || !node.metadata.expression.has_state) && trimmed.some((node) => node.type === 'ExpressionTag'); if (use_text_content) { const { value } = build_template_chunk(trimmed, context.visit, child_state); const empty_string = value.type === 'Literal' && value.value === ''; if (!empty_string) { child_state.init.push( b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value)) ); } } else { /** @type {Expression} */ let arg = context.state.node; // If `hydrate_node` is set inside the element, we need to reset it // after the element has been hydrated let needs_reset = trimmed.some((node) => node.type !== 'Text'); // The same applies if it's a `<template>` element, since we need to // set the value of `hydrate_node` to `node.content` if (node.name === 'template') { needs_reset = true; child_state.init.push(b.stmt(b.call('$.hydrate_template', arg))); arg = b.member(arg, 'content'); } process_children(trimmed, (is_text) => b.call('$.child', arg, is_text && b.true), true, { ...context, state: child_state }); if (needs_reset) { child_state.init.push(b.stmt(b.call('$.reset', context.state.node))); } } if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock')) { // Wrap children in `{...}` to avoid declaration conflicts context.state.init.push( b.block([ ...child_state.init, ...element_state.init, child_state.update.length > 0 ? build_render_statement(child_state) : b.empty, ...child_state.after_update, ...element_state.after_update ]) ); } else if (node.fragment.metadata.dynamic) { context.state.init.push(...child_state.init, ...element_state.init); context.state.update.push(...child_state.update); context.state.after_update.push(...child_state.after_update, ...element_state.after_update); } else { context.state.init.push(...element_state.init); context.state.after_update.push(...element_state.after_update); } if (lookup.has('dir')) { // This fixes an issue with Chromium where updates to text content within an element // does not update the direction when set to auto. If we just re-assign the dir, this fixes it. const dir = b.member(node_id, 'dir'); context.state.update.push(b.stmt(b.assignment('=', dir, dir))); } if (state.locations.length > 0) { // @ts-expect-error location.push(state.locations); } if (!is_void(node.name)) { context.state.template.push(`</${node.name}>`); } } /** * Special case: if we have a value binding on a select element, we need to set up synchronization * between the value binding and inner signals, for indirect updates * @param {AST.BindDirective} value_binding * @param {ComponentContext} context */ function setup_select_synchronization(value_binding, context) { if (context.state.analysis.runes) return; let bound = value_binding.expression; if (bound.type === 'SequenceExpression') { return; } while (bound.type === 'MemberExpression') { bound = /** @type {Identifier | MemberExpression} */ (bound.object); } /** @type {string[]} */ const names = []; for (const [name, refs] of context.state.scope.references) { if ( refs.length > 0 && // prevent infinite loop name !== bound.name ) { names.push(name); } } const invalidator = b.call( '$.invalidate_inner_signals', b.thunk( b.block( names.map((name) => { const serialized = build_getter(b.id(name), context.state); return b.stmt(serialized); }) ) ) ); context.state.init.push( b.stmt( b.call( '$.template_effect', b.thunk( b.block([b.stmt(/** @type {Expression} */ (context.visit(bound))), b.stmt(invalidator)]) ) ) ) ); } /** * @param {AST.ClassDirective[]} class_directives * @param {ComponentContext} context * @return {ObjectExpression | Identifier} */ export function build_class_directives_object(class_directives, context) { let properties = []; let has_call_or_state = false; for (const d of class_directives) { const expression = /** @type Expression */ (context.visit(d.expression)); properties.push(b.init(d.name, expression)); has_call_or_state ||= d.metadata.expression.has_call || d.metadata.expression.has_state; } const directives = b.object(properties); return has_call_or_state ? get_expression_id(context.state, directives) : directives; } /** * @param {AST.StyleDirective[]} style_directives * @param {ComponentContext} context * @return {ObjectExpression | ArrayExpression}} */ export function build_style_directives_object(style_directives, context) { let normal_properties = []; let important_properties = []; for (const directive of style_directives) { const expression = directive.value === true ? build_getter({ name: directive.name, type: 'Identifier' }, context.state) : build_attribute_value(directive.value, context, (value, metadata) => metadata.has_call ? get_expression_id(context.state, value) : value ).value; const property = b.init(directive.name, expression); if (directive.modifiers.includes('important')) { important_properties.push(property); } else { normal_properties.push(property); } } return important_properties.length ? b.array([b.object(normal_properties), b.object(important_properties)]) : b.object(normal_properties); } /** * Serializes an assignment to an element property by adding relevant statements to either only * the init or the the init and update arrays, depending on whether or not the value is dynamic. * Resulting code for static looks something like this: * ```js * element.property = value; * // or * $.set_attribute(element, property, value); * }); * ``` * Resulting code for dynamic looks something like this: * ```js * let value; * $.template_effect(() => { * if (value !== (value = 'new value')) { * element.property = value; * // or * $.set_attribute(element, property, value); * } * }); * ``` * Returns true if attribute is deemed reactive, false otherwise. * @param {AST.RegularElement} element * @param {Identifier} node_id * @param {string} name * @param {Expression} value * @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes */ function build_element_attribute_update(element, node_id, name, value, attributes) { if (name === 'muted') { // Special case for Firefox who needs it set as a property in order to work return b.assignment('=', b.member(node_id, b.id('muted')), value); } if (name === 'value') { return b.call('$.set_value', node_id, value); } if (name === 'checked') { return b.call('$.set_checked', node_id, value); } if (name === 'selected') { return b.call('$.set_selected', node_id, value); } if ( // If we would just set the defaultValue property, it would override the value property, // because it is set in the template which implicitly means it's also setting the default value, // and if one updates the default value while the input is pristine it will also update the // current value, which is not what we want, which is why we need to do some extra work. name === 'defaultValue' && (attributes.some( (attr) => attr.type === 'Attribute' && attr.name === 'value' && is_text_attribute(attr) ) || (element.name === 'textarea' && element.fragment.nodes.length > 0)) ) { return b.call('$.set_default_value', node_id, value); } if ( // See defaultValue comment name === 'defaultChecked' && attributes.some( (attr) => attr.type === 'Attribute' && attr.name === 'checked' && attr.value === true ) ) { return b.call('$.set_default_checked', node_id, value); } if (is_dom_property(name)) { return b.assignment('=', b.member(node_id, name), value); } return b.call( name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute', node_id, b.literal(name), value, is_ignored(element, 'hydration_attribute_changed') && b.true ); } /** * Like `build_element_attribute_update` but without any special attribute treatment. * @param {Identifier} node_id * @param {AST.Attribute} attribute * @param {ComponentContext} context */ function build_custom_element_attribute_update_assignment(node_id, attribute, context) { const { value, has_state } = build_attribute_value(attribute.value, context); // don't lowercase name, as we set the element's property, which might be case sensitive const call = b.call('$.set_custom_element_data', node_id, b.literal(attribute.name), value); // this is different from other updates — it doesn't get grouped, // because set_custom_element_data may not be idempotent const update = has_state ? b.call('$.template_effect', b.thunk(call)) : call; context.state.init.push(b.stmt(update)); } /** * Serializes an assignment to the value property of a `<select>`, `<option>` or `<input>` element * that needs the hidden `__value` property. * Returns true if attribute is deemed reactive, false otherwise. * @param {string} element * @param {Identifier} node_id * @param {AST.Attribute} attribute * @param {ComponentContext} context */ function build_element_special_value_attribute(element, node_id, attribute, context) { const state = context.state; const is_select_with_value = // attribute.metadata.dynamic would give false negatives because even if the value does not change, // the inner options could still change, so we need to always treat it as reactive element === 'select' && attribute.value !== true && !is_text_attribute(attribute); const { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) => metadata.has_call ? // if is a select with value we will also invoke `init_select` which need a reference before the template effect so we memoize separately is_select_with_value ? memoize_expression(state, value) : get_expression_id(state, value) : value ); const inner_assignment = b.assignment( '=', b.member(node_id, 'value'), b.conditional( b.binary('==', b.null, b.assignment('=', b.member(node_id, '__value'), value)), b.literal(''), // render null/undefined values as empty string to support placeholder options value ) ); const update = b.stmt( is_select_with_value ? b.sequence([ inner_assignment, // This ensures a one-way street to the DOM in case it's <select {value}> // and not <select bind:value>. We need it in addition to $.init_select // because the select value is not reflected as an attribute, so the // mutation observer wouldn't notice. b.call('$.select_option', node_id, value) ]) : inner_assignment ); if (is_select_with_value) { state.init.push(b.stmt(b.call('$.init_select', node_id, b.thunk(value)))); } if (has_state) { const id = state.scope.generate(`${node_id.name}_value`); build_update_assignment( state, id, // `<option>` is a special case: The value property reflects to the DOM. If the value is set to undefined, // that means the value should be set to the empty string. To be able to do that when the value is // initially undefined, we need to set a value that is guaranteed to be different. element === 'option' ? b.object([]) : undefined, value, update ); } else { state.init.push(update); } }