import deindent from '../utils/deindent'; import { stringify, escape } from '../utils/stringify'; import CodeBuilder from '../utils/CodeBuilder'; import Component from '../Component'; import Renderer from './Renderer'; import { CompileOptions } from '../../interfaces'; import { walk } from 'estree-walker'; import { stringify_props } from '../utils/stringify_props'; import add_to_set from '../utils/add_to_set'; import get_object from '../utils/get_object'; import { extract_names } from '../utils/scope'; import { nodes_match } from '../../utils/nodes_match'; export default function dom( component: Component, options: CompileOptions ) { const { name, code } = component; const renderer = new Renderer(component, options); const { block } = renderer; block.has_outro_method = true; // prevent fragment being created twice (#1063) if (options.customElement) block.builders.create.add_line(`this.c = @noop;`); const builder = new CodeBuilder(); if (component.compile_options.dev) { builder.add_line(`const ${renderer.file_var} = ${component.file && stringify(component.file, { only_escape_at_symbol: true })};`); } const css = component.stylesheet.render(options.filename, !options.customElement); const styles = component.stylesheet.has_styles && stringify(options.dev ? `${css.code}\n/*# sourceMappingURL=${css.map.toUrl()} */` : css.code, { only_escape_at_symbol: true }); const add_css = component.get_unique_name('add_css'); if (styles && component.compile_options.css !== false && !options.customElement) { builder.add_block(deindent` function ${add_css}() { var style = @element("style"); style.id = '${component.stylesheet.id}-style'; style.textContent = ${styles}; @append(@_document.head, style); } `); } // fix order // TODO the deconflicted names of blocks are reversed... should set them here const blocks = renderer.blocks.slice().reverse(); blocks.forEach(block => { builder.add_block(block.toString()); }); if (options.dev && !options.hydratable) { block.builders.claim.add_line( 'throw new @_Error("options.hydrate only works if the component was compiled with the `hydratable: true` option");' ); } // TODO injecting CSS this way is kinda dirty. Maybe it should be an // explicit opt-in, or something? const should_add_css = ( !options.customElement && component.stylesheet.has_styles && options.css !== false ); const uses_props = component.var_lookup.has('$$props'); const $$props = uses_props ? `$$new_props` : `$$props`; const props = component.vars.filter(variable => !variable.module && variable.export_name); const writable_props = props.filter(variable => variable.writable); /* eslint-disable @typescript-eslint/indent,indent */ const set = (uses_props || writable_props.length > 0 || component.slots.size > 0) ? deindent` ${$$props} => { ${uses_props && component.invalidate('$$props', `$$props = @assign(@assign({}, $$props), $$new_props)`)} ${writable_props.map(prop => `if ('${prop.export_name}' in ${$$props}) ${component.invalidate(prop.name, `${prop.name} = ${$$props}.${prop.export_name}`)};` )} ${component.slots.size > 0 && `if ('$$scope' in ${$$props}) ${component.invalidate('$$scope', `$$scope = ${$$props}.$$scope`)};`} } ` : null; /* eslint-enable @typescript-eslint/indent,indent */ const body = []; const not_equal = component.component_options.immutable ? `@not_equal` : `@safe_not_equal`; let dev_props_check; props.forEach(x => { const variable = component.var_lookup.get(x.name); if (!variable.writable || component.component_options.accessors) { body.push(deindent` get ${x.export_name}() { return ${x.hoistable ? x.name : 'this.$$.ctx.' + x.name}; } `); } else if (component.compile_options.dev) { body.push(deindent` get ${x.export_name}() { throw new @_Error("<${component.tag}>: Props cannot be read directly from the component instance unless compiling with 'accessors: true' or '<svelte:options accessors/>'"); } `); } if (component.component_options.accessors) { if (variable.writable && !renderer.readonly.has(x.name)) { body.push(deindent` set ${x.export_name}(${x.name}) { this.$set({ ${x.name === x.export_name ? x.name : `${x.export_name}: ${x.name}`} }); @flush(); } `); } else if (component.compile_options.dev) { body.push(deindent` set ${x.export_name}(value) { throw new @_Error("<${component.tag}>: Cannot set read-only property '${x.export_name}'"); } `); } } else if (component.compile_options.dev) { body.push(deindent` set ${x.export_name}(value) { throw new @_Error("<${component.tag}>: Props cannot be set directly on the component instance unless compiling with 'accessors: true' or '<svelte:options accessors/>'"); } `); } }); if (component.compile_options.dev) { // checking that expected ones were passed const expected = props.filter(prop => !prop.initialised); if (expected.length) { dev_props_check = deindent` const { ctx } = this.$$; const props = ${options.customElement ? `this.attributes` : `options.props || {}`}; ${expected.map(prop => deindent` if (ctx.${prop.name} === undefined && !('${prop.export_name}' in props)) { @_console.warn("<${component.tag}> was created without expected prop '${prop.export_name}'"); }`)} `; } } // instrument assignments if (component.ast.instance) { let scope = component.instance_scope; const map = component.instance_scope_map; let pending_assignments = new Set(); walk(component.ast.instance.content, { enter: (node) => { if (map.has(node)) { scope = map.get(node); } }, leave(node, parent) { if (map.has(node)) { scope = scope.parent; } if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') { const assignee = node.type === 'AssignmentExpression' ? node.left : node.argument; let names = []; if (assignee.type === 'MemberExpression') { const left_object_name = get_object(assignee).name; left_object_name && (names = [left_object_name]); } else { names = extract_names(assignee); } if (node.operator === '=' && nodes_match(node.left, node.right)) { const dirty = names.filter(name => { return name[0] === '$' || scope.find_owner(name) === component.instance_scope; }); if (dirty.length) component.has_reactive_assignments = true; code.overwrite(node.start, node.end, dirty.map(n => component.invalidate(n)).join('; ')); } else { const single = ( node.type === 'AssignmentExpression' && assignee.type === 'Identifier' && parent.type === 'ExpressionStatement' && assignee.name[0] !== '$' ); names.forEach(name => { const owner = scope.find_owner(name); if (owner && owner !== component.instance_scope) return; const variable = component.var_lookup.get(name); if (variable && (variable.hoistable || variable.global || variable.module)) return; if (single && !(variable.subscribable && variable.reassigned)) { if (variable.referenced || variable.is_reactive_dependency || variable.export_name) { code.prependRight(node.start, `$$invalidate('${name}', `); code.appendLeft(node.end, `)`); } } else { pending_assignments.add(name); } component.has_reactive_assignments = true; }); } } if (pending_assignments.size > 0) { if (node.type === 'ArrowFunctionExpression') { const insert = Array.from(pending_assignments).map(name => component.invalidate(name)).join('; '); pending_assignments = new Set(); code.prependRight(node.body.start, `{ const $$result = `); code.appendLeft(node.body.end, `; ${insert}; return $$result; }`); pending_assignments = new Set(); } else if (/Statement/.test(node.type)) { const insert = Array.from(pending_assignments).map(name => component.invalidate(name)).join('; '); if (/^(Break|Continue|Return)Statement/.test(node.type)) { if (node.argument) { code.overwrite(node.start, node.argument.start, `var $$result = `); code.appendLeft(node.argument.end, `; ${insert}; return $$result`); } else { code.prependRight(node.start, `${insert}; `); } } else if (parent && /(If|For(In|Of)?|While)Statement/.test(parent.type) && node.type !== 'BlockStatement') { code.prependRight(node.start, '{ '); code.appendLeft(node.end, `${code.original[node.end - 1] === ';' ? '' : ';'} ${insert}; }`); } else { code.appendLeft(node.end, `${code.original[node.end - 1] === ';' ? '' : ';'} ${insert};`); } pending_assignments = new Set(); } } } }); if (pending_assignments.size > 0) { throw new Error(`TODO this should not happen!`); } component.rewrite_props(({ name, reassigned }) => { const value = `$${name}`; const callback = `$value => { ${value} = $$value; $$invalidate('${value}', ${value}) }`; if (reassigned) { return `$$subscribe_${name}()`; } const subscribe = component.helper('subscribe'); let insert = `${subscribe}($$self, ${name}, $${callback})`; if (component.compile_options.dev) { const validate_store = component.helper('validate_store'); insert = `${validate_store}(${name}, '${name}'); ${insert}`; } return insert; }); } const args = ['$$self']; if (props.length > 0 || component.has_reactive_assignments || component.slots.size > 0) { args.push('$$props', '$$invalidate'); } builder.add_block(deindent` function create_fragment(ctx) { ${block.get_contents()} } ${component.module_javascript} ${component.fully_hoisted.length > 0 && component.fully_hoisted.join('\n\n')} `); const filtered_declarations = component.vars .filter(v => ((v.referenced || v.export_name) && !v.hoistable)) .map(v => v.name); if (uses_props) filtered_declarations.push(`$$props: $$props = ${component.helper('exclude_internal_props')}($$props)`); const filtered_props = props.filter(prop => { const variable = component.var_lookup.get(prop.name); if (variable.hoistable) return false; if (prop.name[0] === '$') return false; return true; }); const reactive_stores = component.vars.filter(variable => variable.name[0] === '$' && variable.name[1] !== '$'); if (component.slots.size > 0) { filtered_declarations.push('$$slots', '$$scope'); } if (renderer.binding_groups.length > 0) { filtered_declarations.push(`$$binding_groups`); } const has_definition = ( component.javascript || filtered_props.length > 0 || uses_props || component.partly_hoisted.length > 0 || filtered_declarations.length > 0 || component.reactive_declarations.length > 0 ); const definition = has_definition ? component.alias('instance') : 'null'; const all_reactive_dependencies = new Set(); component.reactive_declarations.forEach(d => { add_to_set(all_reactive_dependencies, d.dependencies); }); const reactive_store_subscriptions = reactive_stores .filter(store => { const variable = component.var_lookup.get(store.name.slice(1)); return !variable || variable.hoistable; }) .map(({ name }) => deindent` ${component.compile_options.dev && `@validate_store(${name.slice(1)}, '${name.slice(1)}');`} @subscribe($$self, ${name.slice(1)}, $$value => { ${name} = $$value; $$invalidate('${name}', ${name}); }); `); const resubscribable_reactive_store_unsubscribers = reactive_stores .filter(store => { const variable = component.var_lookup.get(store.name.slice(1)); return variable && variable.reassigned; }) .map(({ name }) => `$$self.$$.on_destroy.push(() => $$unsubscribe_${name.slice(1)}());`); if (has_definition) { const reactive_declarations = []; const fixed_reactive_declarations = []; // not really 'reactive' but whatever component.reactive_declarations .forEach(d => { let uses_props; const condition = Array.from(d.dependencies) .filter(n => { if (n === '$$props') { uses_props = true; return false; } const variable = component.var_lookup.get(n); return variable && (variable.writable || variable.mutated); }) .map(n => `$$dirty.${n}`).join(' || '); let snippet = `[✂${d.node.body.start}-${d.node.end}✂]`; if (condition) snippet = `if (${condition}) { ${snippet} }`; if (condition || uses_props) { reactive_declarations.push(snippet); } else { fixed_reactive_declarations.push(snippet); } }); const injected = Array.from(component.injected_reactive_declaration_vars).filter(name => { const variable = component.var_lookup.get(name); return variable.injected && variable.name[0] !== '$'; }); const reactive_store_declarations = reactive_stores.map(variable => { const $name = variable.name; const name = $name.slice(1); const store = component.var_lookup.get(name); if (store && store.reassigned) { return `${$name}, $$unsubscribe_${name} = @noop, $$subscribe_${name} = () => { $$unsubscribe_${name}(); $$unsubscribe_${name} = ${name}.subscribe($$value => { ${$name} = $$value; $$invalidate('${$name}', ${$name}); }) }`; } return $name; }); let unknown_props_check; if (component.compile_options.dev && !component.var_lookup.has('$$props') && writable_props.length) { unknown_props_check = deindent` const writable_props = [${writable_props.map(prop => `'${prop.export_name}'`).join(', ')}]; @_Object.keys($$props).forEach(key => { if (!writable_props.includes(key) && !key.startsWith('$$')) @_console.warn(\`<${component.tag}> was created with unknown prop '\${key}'\`); }); `; } builder.add_block(deindent` function ${definition}(${args.join(', ')}) { ${reactive_store_declarations.length > 0 && `let ${reactive_store_declarations.join(', ')};`} ${reactive_store_subscriptions} ${resubscribable_reactive_store_unsubscribers} ${component.javascript} ${unknown_props_check} ${component.slots.size && `let { $$slots = {}, $$scope } = $$props;`} ${renderer.binding_groups.length > 0 && `const $$binding_groups = [${renderer.binding_groups.map(_ => `[]`).join(', ')}];`} ${component.partly_hoisted.length > 0 && component.partly_hoisted.join('\n\n')} ${set && `$$self.$set = ${set};`} ${injected.length && `let ${injected.join(', ')};`} ${reactive_declarations.length > 0 && deindent` $$self.$$.update = ($$dirty = { ${Array.from(all_reactive_dependencies).map(n => `${n}: 1`).join(', ')} }) => { ${reactive_declarations} }; `} ${fixed_reactive_declarations} return ${stringify_props(filtered_declarations)}; } `); } const prop_names = `[${props.map(v => JSON.stringify(v.export_name)).join(', ')}]`; if (options.customElement) { builder.add_block(deindent` class ${name} extends @SvelteElement { constructor(options) { super(); ${css.code && `this.shadowRoot.innerHTML = \`<style>${escape(css.code, { only_escape_at_symbol: true }).replace(/\\/g, '\\\\')}${options.dev ? `\n/*# sourceMappingURL=${css.map.toUrl()} */` : ''}</style>\`;`} @init(this, { target: this.shadowRoot }, ${definition}, create_fragment, ${not_equal}, ${prop_names}); ${dev_props_check} if (options) { if (options.target) { @insert(options.target, this, options.anchor); } ${(props.length > 0 || uses_props) && deindent` if (options.props) { this.$set(options.props); @flush(); }`} } } ${props.length > 0 && deindent` static get observedAttributes() { return ${JSON.stringify(props.map(x => x.export_name))}; }`} ${body.length > 0 && body.join('\n\n')} } `); if (component.tag != null) { builder.add_block(deindent` @_customElements.define("${component.tag}", ${name}); `); } } else { const superclass = options.dev ? 'SvelteComponentDev' : 'SvelteComponent'; builder.add_block(deindent` class ${name} extends @${superclass} { constructor(options) { super(${options.dev && `options`}); ${should_add_css && `if (!@_document.getElementById("${component.stylesheet.id}-style")) ${add_css}();`} @init(this, options, ${definition}, create_fragment, ${not_equal}, ${prop_names}); ${options.dev && `@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name}", id: create_fragment.name });`} ${dev_props_check} } ${body.length > 0 && body.join('\n\n')} } `); } return builder.toString(); }