diff --git a/package.json b/package.json index 21885c6ad5..9bd16c1b10 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ ], "scripts": { "test": "mocha --opts mocha.opts", + "test:unit": "mocha --require sucrase/register --recursive ./**/__test__.ts", "quicktest": "mocha --opts mocha.opts", "precoverage": "c8 mocha --opts mocha.coverage.opts", "coverage": "c8 report --reporter=text-lcov > coverage.lcov && c8 report --reporter=html", diff --git a/src/Stats.ts b/src/Stats.ts index a27ff98105..87c6c2173a 100644 --- a/src/Stats.ts +++ b/src/Stats.ts @@ -1,6 +1,3 @@ -import { Warning } from './interfaces'; -import Component from './compile/Component'; - const now = (typeof process !== 'undefined' && process.hrtime) ? () => { const t = process.hrtime(); @@ -31,14 +28,11 @@ export default class Stats { currentChildren: Timing[]; timings: Timing[]; stack: Timing[]; - warnings: Warning[]; constructor() { this.startTime = now(); this.stack = []; this.currentChildren = this.timings = []; - - this.warnings = []; } start(label) { @@ -67,46 +61,13 @@ export default class Stats { this.currentChildren = this.currentTiming ? this.currentTiming.children : this.timings; } - render(component: Component) { + render() { const timings = Object.assign({ total: now() - this.startTime }, collapseTimings(this.timings)); - // TODO would be good to have this info even - // if options.generate is false - const imports = component && component.imports.map(node => { - return { - source: node.source.value, - specifiers: node.specifiers.map(specifier => { - return { - name: ( - specifier.type === 'ImportDefaultSpecifier' ? 'default' : - specifier.type === 'ImportNamespaceSpecifier' ? '*' : - specifier.imported.name - ), - as: specifier.local.name - }; - }) - } - }); - return { - timings, - warnings: this.warnings, - vars: component.vars.filter(variable => !variable.global && !variable.implicit && !variable.internal).map(variable => ({ - name: variable.name, - export_name: variable.export_name || null, - injected: variable.injected || false, - module: variable.module || false, - mutated: variable.mutated || false, - reassigned: variable.reassigned || false, - referenced: variable.referenced || false, - writable: variable.writable || false - })) + timings }; } - - warn(warning) { - this.warnings.push(warning); - } } diff --git a/src/compile/Component.ts b/src/compile/Component.ts index 09f5261c63..905f81d60f 100644 --- a/src/compile/Component.ts +++ b/src/compile/Component.ts @@ -11,7 +11,7 @@ import Stylesheet from './css/Stylesheet'; import { test } from '../config'; import Fragment from './nodes/Fragment'; import internal_exports from './internal-exports'; -import { Node, Ast, CompileOptions, Var } from '../interfaces'; +import { Node, Ast, CompileOptions, Var, Warning } from '../interfaces'; import error from '../utils/error'; import getCodeFrame from '../utils/getCodeFrame'; import flattenReference from '../utils/flattenReference'; @@ -40,6 +40,7 @@ childKeys.ExportNamedDeclaration = ['declaration', 'specifiers']; export default class Component { stats: Stats; + warnings: Warning[]; ast: Ast; source: string; @@ -93,11 +94,13 @@ export default class Component { source: string, name: string, compileOptions: CompileOptions, - stats: Stats + stats: Stats, + warnings: Warning[] ) { this.name = name; this.stats = stats; + this.warnings = warnings; this.ast = ast; this.source = source; this.compileOptions = compileOptions; @@ -161,7 +164,7 @@ export default class Component { if (!compileOptions.customElement) this.stylesheet.reify(); - this.stylesheet.warnOnUnusedSelectors(stats); + this.stylesheet.warnOnUnusedSelectors(this); } add_var(variable: Var) { @@ -183,7 +186,11 @@ export default class Component { writable: true }); - this.add_reference(name.slice(1)); + const subscribable_name = name.slice(1); + this.add_reference(subscribable_name); + + const variable = this.var_lookup.get(subscribable_name); + variable.subscribable = true; } else if (!this.ast.instance) { this.add_var({ name, @@ -213,106 +220,127 @@ export default class Component { return this.aliases.get(name); } + helper(name: string) { + this.helpers.add(name); + return this.alias(name); + } + generate(result: string) { - const { compileOptions, name } = this; - const { format = 'esm' } = compileOptions; + let js = null; + let css = null; - const banner = `/* ${this.file ? `${this.file} ` : ``}generated by Svelte v${"__VERSION__"} */`; + if (result) { + const { compileOptions, name } = this; + const { format = 'esm' } = compileOptions; - // TODO use same regex for both - result = result.replace(compileOptions.generate === 'ssr' ? /(@+|#+)(\w*(?:-\w*)?)/g : /(@+)(\w*(?:-\w*)?)/g, (match: string, sigil: string, name: string) => { - if (sigil === '@') { - if (internal_exports.has(name)) { - if (compileOptions.dev && internal_exports.has(`${name}Dev`)) name = `${name}Dev`; - this.helpers.add(name); - } + const banner = `/* ${this.file ? `${this.file} ` : ``}generated by Svelte v${"__VERSION__"} */`; - return this.alias(name); - } + // TODO use same regex for both + result = result.replace(compileOptions.generate === 'ssr' ? /(@+|#+)(\w*(?:-\w*)?)/g : /(@+)(\w*(?:-\w*)?)/g, (match: string, sigil: string, name: string) => { + if (sigil === '@') { + if (internal_exports.has(name)) { + if (compileOptions.dev && internal_exports.has(`${name}Dev`)) name = `${name}Dev`; + this.helpers.add(name); + } - return sigil.slice(1) + name; - }); + return this.alias(name); + } - const importedHelpers = Array.from(this.helpers) - .sort() - .map(name => { - const alias = this.alias(name); - return { name, alias }; + return sigil.slice(1) + name; }); - const module = wrapModule( - result, - format, - name, - compileOptions, - banner, - compileOptions.sveltePath, - importedHelpers, - this.imports, - this.vars.filter(variable => variable.module && variable.export_name).map(variable => ({ - name: variable.name, - as: variable.export_name - })), - this.source - ); + const importedHelpers = Array.from(this.helpers) + .sort() + .map(name => { + const alias = this.alias(name); + return { name, alias }; + }); - const parts = module.split('✂]'); - const finalChunk = parts.pop(); + const module = wrapModule( + result, + format, + name, + compileOptions, + banner, + compileOptions.sveltePath, + importedHelpers, + this.imports, + this.vars.filter(variable => variable.module && variable.export_name).map(variable => ({ + name: variable.name, + as: variable.export_name + })), + this.source + ); - const compiled = new Bundle({ separator: '' }); + const parts = module.split('✂]'); + const finalChunk = parts.pop(); - function addString(str: string) { - compiled.addSource({ - content: new MagicString(str), - }); - } + const compiled = new Bundle({ separator: '' }); - const { filename } = compileOptions; + function addString(str: string) { + compiled.addSource({ + content: new MagicString(str), + }); + } - // special case — the source file doesn't actually get used anywhere. we need - // to add an empty file to populate map.sources and map.sourcesContent - if (!parts.length) { - compiled.addSource({ - filename, - content: new MagicString(this.source).remove(0, this.source.length), - }); - } + const { filename } = compileOptions; - const pattern = /\[✂(\d+)-(\d+)$/; + // special case — the source file doesn't actually get used anywhere. we need + // to add an empty file to populate map.sources and map.sourcesContent + if (!parts.length) { + compiled.addSource({ + filename, + content: new MagicString(this.source).remove(0, this.source.length), + }); + } + + const pattern = /\[✂(\d+)-(\d+)$/; - parts.forEach((str: string) => { - const chunk = str.replace(pattern, ''); - if (chunk) addString(chunk); + parts.forEach((str: string) => { + const chunk = str.replace(pattern, ''); + if (chunk) addString(chunk); - const match = pattern.exec(str); + const match = pattern.exec(str); - const snippet = this.code.snip(+match[1], +match[2]); + const snippet = this.code.snip(+match[1], +match[2]); - compiled.addSource({ - filename, - content: snippet, + compiled.addSource({ + filename, + content: snippet, + }); }); - }); - addString(finalChunk); + addString(finalChunk); - const css = compileOptions.customElement ? - { code: null, map: null } : - this.stylesheet.render(compileOptions.cssOutputFilename, true); + css = compileOptions.customElement ? + { code: null, map: null } : + this.stylesheet.render(compileOptions.cssOutputFilename, true); - const js = { - code: compiled.toString(), - map: compiled.generateMap({ - includeContent: true, - file: compileOptions.outputFilename, - }) - }; + js = { + code: compiled.toString(), + map: compiled.generateMap({ + includeContent: true, + file: compileOptions.outputFilename, + }) + }; + } return { - ast: this.ast, js, css, - stats: this.stats.render(this) + ast: this.ast, + warnings: this.warnings, + vars: this.vars.filter(v => !v.global && !v.implicit && !v.internal).map(v => ({ + name: v.name, + export_name: v.export_name || null, + injected: v.injected || false, + module: v.module || false, + mutated: v.mutated || false, + reassigned: v.reassigned || false, + referenced: v.referenced || false, + writable: v.writable || false + })), + stats: this.stats.render() }; } @@ -393,7 +421,7 @@ export default class Component { const frame = getCodeFrame(this.source, start.line - 1, start.column); - this.stats.warn({ + this.warnings.push({ code: warning.code, message: warning.message, frame, @@ -641,6 +669,9 @@ export default class Component { }); this.add_reference(name.slice(1)); + + const variable = this.var_lookup.get(name.slice(1)); + variable.subscribable = true; } else { this.add_var({ name, @@ -738,7 +769,15 @@ export default class Component { }); } - rewrite_props() { + invalidate(name, value = name) { + const variable = this.var_lookup.get(name); + if (variable && (variable.subscribable && variable.reassigned)) { + return `$$subscribe_${name}(), $$invalidate('${name}', ${value})`; + } + return `$$invalidate('${name}', ${value})`; + } + + rewrite_props(get_insert: (variable: Var) => string) { const component = this; const { code, instance_scope, instance_scope_map: map, componentOptions } = this; let scope = instance_scope; @@ -759,72 +798,97 @@ export default class Component { if (node.type === 'VariableDeclaration') { if (node.kind === 'var' || scope === instance_scope) { - let has_exports = false; - let has_only_exports = true; + node.declarations.forEach((declarator, i) => { + const next = node.declarations[i + 1]; - node.declarations.forEach(declarator => { - extractNames(declarator.id).forEach(name => { - const variable = component.var_lookup.get(name); + if (declarator.id.type !== 'Identifier') { + const inserts = []; - if (name === componentOptions.props_object) { - if (variable.export_name) { - component.error(declarator, { - code: 'exported-options-props', - message: `Cannot export props binding` - }); - } + extractNames(declarator.id).forEach(name => { + const variable = component.var_lookup.get(name); - if (declarator.id.type !== 'Identifier') { + if (variable.export_name || name === componentOptions.props_object) { component.error(declarator, { - code: 'todo', - message: `props binding in destructured declaration is not yet supported` + code: 'destructured-prop', + message: `Cannot declare props in destructured declaration` }); } - // can't use the @ trick here, because we're - // manipulating the underlying magic string - component.helpers.add('exclude_internal_props'); - const exclude_internal_props = component.alias('exclude_internal_props'); - - const suffix = code.original[declarator.end] === ';' - ? ` = ${exclude_internal_props}($$props)` - : ` = ${exclude_internal_props}($$props);` + if (variable.subscribable) { + inserts.push(get_insert(variable)); + } + }); - if (declarator.id.end === declarator.end) { - code.appendLeft(declarator.end, suffix); + if (inserts.length > 0) { + if (next) { + code.overwrite(declarator.end, next.start, `; ${inserts.join('; ')}; ${node.kind} `); } else { - code.overwrite(declarator.id.end, declarator.end, suffix); + code.appendLeft(declarator.end, `; ${inserts.join('; ')}`); } } + return; + } + + const { name } = declarator.id; + const variable = component.var_lookup.get(name); + + if (name === componentOptions.props_object) { if (variable.export_name) { - has_exports = true; + component.error(declarator, { + code: 'exported-options-props', + message: `Cannot export props binding` + }); + } + + // can't use the @ trick here, because we're + // manipulating the underlying magic string + const exclude_internal_props = component.helper('exclude_internal_props'); + + const suffix = code.original[declarator.end] === ';' + ? ` = ${exclude_internal_props}($$props)` + : ` = ${exclude_internal_props}($$props);` + + if (declarator.id.end === declarator.end) { + code.appendLeft(declarator.end, suffix); } else { - has_only_exports = false; + code.overwrite(declarator.id.end, declarator.end, suffix); } - }); - }); + } - if (has_only_exports) { - if (current_group && current_group[current_group.length - 1].kind !== node.kind) { + if (variable.export_name) { + if (variable.subscribable) { + coalesced_declarations.push({ + kind: node.kind, + declarators: [declarator], + insert: get_insert(variable) + }); + } else { + if (current_group && current_group.kind !== node.kind) { + current_group = null; + } + + if (!current_group) { + current_group = { kind: node.kind, declarators: [], insert: null }; + coalesced_declarations.push(current_group); + } + + current_group.declarators.push(declarator); + } + } else { current_group = null; - } - // rewrite as a group, later - if (!current_group) { - current_group = []; - coalesced_declarations.push(current_group); - } + if (variable.subscribable) { + let insert = get_insert(variable); - current_group.push(node); - } else { - if (has_exports) { - // rewrite in place - throw new Error('TODO rewrite prop declaration in place'); + if (next) { + code.overwrite(declarator.end, next.start, `; ${insert}; ${node.kind} `); + } else { + code.appendLeft(declarator.end, `; ${insert}`); + } + } } - - current_group = null; - } + }); } } else { if (node.type !== 'ExportNamedDeclaration') { @@ -845,31 +909,25 @@ export default class Component { let combining = false; - group.forEach(node => { - node.declarations.forEach(declarator => { - const { id, init } = declarator; + group.declarators.forEach(declarator => { + const { id } = declarator; - if (id.type === 'Identifier') { - const value = init - ? this.code.slice(id.start, init.end) - : this.code.slice(id.start, id.end); - - if (combining) { - code.overwrite(c, id.start, ', '); - } else { - code.appendLeft(id.start, '{ '); - combining = true; - } - } else { - throw new Error('TODO destructured declarations'); - } + if (combining) { + code.overwrite(c, id.start, ', '); + } else { + code.appendLeft(id.start, '{ '); + combining = true; + } - c = declarator.end; - }); + c = declarator.end; }); if (combining) { - const suffix = code.original[c] === ';' ? ` } = $$props` : ` } = $$props;`; + const insert = group.insert + ? `; ${group.insert}` + : ''; + + const suffix = code.original[c] === ';' ? ` } = $$props${insert}` : ` } = $$props${insert};`; code.appendLeft(c, suffix); } }); diff --git a/src/compile/css/Stylesheet.ts b/src/compile/css/Stylesheet.ts index 0a36086765..aa4db3b5de 100644 --- a/src/compile/css/Stylesheet.ts +++ b/src/compile/css/Stylesheet.ts @@ -1,14 +1,11 @@ import MagicString from 'magic-string'; import { walk } from 'estree-walker'; -import { getLocator } from 'locate-character'; import Selector from './Selector'; -import getCodeFrame from '../../utils/getCodeFrame'; import hash from '../../utils/hash'; import removeCSSPrefix from '../../utils/removeCSSPrefix'; import Element from '../nodes/Element'; -import { Node, Ast, Warning } from '../../interfaces'; +import { Node, Ast } from '../../interfaces'; import Component from '../Component'; -import Stats from '../../Stats'; const isKeyframesNode = (node: Node) => removeCSSPrefix(node.name) === 'keyframes' @@ -392,33 +389,14 @@ export default class Stylesheet { }); } - warnOnUnusedSelectors(stats: Stats) { - let locator; - - const handler = (selector: Selector) => { - const pos = selector.node.start; - - if (!locator) locator = getLocator(this.source, { offsetLine: 1 }); - const start = locator(pos); - const end = locator(selector.node.end); - - const frame = getCodeFrame(this.source, start.line - 1, start.column); - const message = `Unused CSS selector`; - - stats.warn({ - code: `css-unused-selector`, - message, - frame, - start, - end, - pos, - filename: this.filename, - toString: () => `${message} (${start.line}:${start.column})\n${frame}`, - }); - }; - + warnOnUnusedSelectors(component: Component) { this.children.forEach(child => { - child.warnOnUnusedSelector(handler); + child.warnOnUnusedSelector((selector: Selector) => { + component.warn(selector.node, { + code: `css-unused-selector`, + message: `Unused CSS selector` + }); + }); }); } } diff --git a/src/compile/index.ts b/src/compile/index.ts index 404d933327..704d5bd706 100644 --- a/src/compile/index.ts +++ b/src/compile/index.ts @@ -3,7 +3,7 @@ import Stats from '../Stats'; import parse from '../parse/index'; import renderDOM from './render-dom/index'; import renderSSR from './render-ssr/index'; -import { CompileOptions, Ast } from '../interfaces'; +import { CompileOptions, Ast, Warning } from '../interfaces'; import Component from './Component'; import fuzzymatch from '../utils/fuzzymatch'; @@ -24,7 +24,7 @@ const valid_options = [ 'preserveComments' ]; -function validate_options(options: CompileOptions, stats: Stats) { +function validate_options(options: CompileOptions, warnings: Warning[]) { const { name, filename } = options; Object.keys(options).forEach(key => { @@ -43,7 +43,7 @@ function validate_options(options: CompileOptions, stats: Stats) { if (name && /^[a-z]/.test(name)) { const message = `options.name should be capitalised`; - stats.warn({ + warnings.push({ code: `options-lowercase-name`, message, filename, @@ -74,10 +74,11 @@ export default function compile(source: string, options: CompileOptions = {}) { options = assign({ generate: 'dom', dev: false }, options); const stats = new Stats(); + const warnings = []; let ast: Ast; - validate_options(options, stats); + validate_options(options, warnings); stats.start('parse'); ast = parse(source, options); @@ -89,17 +90,16 @@ export default function compile(source: string, options: CompileOptions = {}) { source, options.name || get_name(options.filename) || 'SvelteComponent', options, - stats + stats, + warnings ); stats.stop('create component'); - if (options.generate === false) { - return { ast, stats: stats.render(component), js: null, css: null }; - } - - const js = options.generate === 'ssr' - ? renderSSR(component, options) - : renderDOM(component, options); + const js = options.generate === false + ? null + : options.generate === 'ssr' + ? renderSSR(component, options) + : renderDOM(component, options); return component.generate(js); } \ No newline at end of file diff --git a/src/compile/nodes/shared/Expression.ts b/src/compile/nodes/shared/Expression.ts index 818066f21a..22a32fa3d2 100644 --- a/src/compile/nodes/shared/Expression.ts +++ b/src/compile/nodes/shared/Expression.ts @@ -122,8 +122,16 @@ export default class Expression { const { name, nodes } = flattenReference(node); if (scope.has(name)) return; + if (globalWhitelist.has(name) && !component.var_lookup.has(name)) return; + if (name[0] === '$' && template_scope.names.has(name.slice(1))) { + component.error(node, { + code: `contextual-store`, + message: `Stores must be declared at the top level of the component (this may change in a future version of Svelte)` + }); + } + if (template_scope.is_let(name)) { if (!function_expression) { dependencies.add(name); @@ -290,7 +298,7 @@ export default class Expression { if (dirty.length) component.has_reactive_assignments = true; - code.overwrite(node.start, node.end, dirty.map(n => `$$invalidate('${n}', ${n})`).join('; ')); + code.overwrite(node.start, node.end, dirty.map(n => component.invalidate(n)).join('; ')); } else { names.forEach(name => { if (scope.declarations.has(name)) return; @@ -356,7 +364,7 @@ export default class Expression { let body = code.slice(node.body.start, node.body.end).trim(); if (node.body.type !== 'BlockStatement') { if (pending_assignments.size > 0) { - const insert = Array.from(pending_assignments).map(name => `$$invalidate('${name}', ${name})`).join('; '); + const insert = Array.from(pending_assignments).map(name => component.invalidate(name)).join('; '); pending_assignments = new Set(); component.has_reactive_assignments = true; @@ -431,7 +439,7 @@ export default class Expression { const insert = ( (has_semi ? ' ' : '; ') + - Array.from(pending_assignments).map(name => `$$invalidate('${name}', ${name})`).join('; ') + Array.from(pending_assignments).map(name => component.invalidate(name)).join('; ') ); if (/^(Break|Continue|Return)Statement/.test(node.type)) { diff --git a/src/compile/render-dom/index.ts b/src/compile/render-dom/index.ts index 14638553a5..067288c0f5 100644 --- a/src/compile/render-dom/index.ts +++ b/src/compile/render-dom/index.ts @@ -79,12 +79,13 @@ export default function dom( ${component.componentOptions.props && deindent` if (!${component.componentOptions.props}) ${component.componentOptions.props} = {}; @assign(${component.componentOptions.props}, $$props); - $$invalidate('${component.componentOptions.props_object}', ${component.componentOptions.props_object}); + ${component.invalidate(component.componentOptions.props_object)}; `} ${writable_props.map(prop => - `if ('${prop.export_name}' in $$props) $$invalidate('${prop.name}', ${prop.name} = $$props.${prop.export_name});`)} + `if ('${prop.export_name}' in $$props) ${component.invalidate(prop.name, `${prop.name} = $$props.${prop.export_name}`)};` + )} ${renderer.slots.size > 0 && - `if ('$$scope' in $$props) $$invalidate('$$scope', $$scope = $$props.$$scope);`} + `if ('$$scope' in $$props) ${component.invalidate('$$scope', `$$scope = $$props.$$scope`)};`} } ` : null; @@ -175,7 +176,7 @@ export default function dom( if (dirty.length) component.has_reactive_assignments = true; - code.overwrite(node.start, node.end, dirty.map(n => `$$invalidate('${n}', ${n})`).join('; ')); + code.overwrite(node.start, node.end, dirty.map(n => component.invalidate(n)).join('; ')); } else { names.forEach(name => { const owner = scope.findOwner(name); @@ -204,7 +205,7 @@ export default function dom( if (pending_assignments.size > 0) { if (node.type === 'ArrowFunctionExpression') { - const insert = Array.from(pending_assignments).map(name => `$$invalidate('${name}', ${name})`).join(';'); + const insert = Array.from(pending_assignments).map(name => component.invalidate(name)).join('; '); pending_assignments = new Set(); code.prependRight(node.body.start, `{ const $$result = `); @@ -214,7 +215,7 @@ export default function dom( } else if (/Statement/.test(node.type)) { - const insert = Array.from(pending_assignments).map(name => `$$invalidate('${name}', ${name})`).join('; '); + const insert = Array.from(pending_assignments).map(name => component.invalidate(name)).join('; '); if (/^(Break|Continue|Return)Statement/.test(node.type)) { if (node.argument) { @@ -240,7 +241,25 @@ export default function dom( throw new Error(`TODO this should not happen!`); } - component.rewrite_props(); + 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.compileOptions.dev) { + const validate_store = component.helper('validate_store'); + insert = `${validate_store}(${name}, '${name}'); ${insert}`; + } + + return insert; + }); } const args = ['$$self']; @@ -301,22 +320,45 @@ export default function dom( addToSet(all_reactive_dependencies, d.dependencies); }); - const user_code = component.javascript || ( - !component.ast.instance && !component.ast.module && (filtered_props.length > 0 || component.componentOptions.props) - ? [ - component.componentOptions.props && `let ${component.componentOptions.props} = $$props;`, - filtered_props.length > 0 && `let { ${filtered_props.map(x => x.name).join(', ')} } = $$props;` - ].filter(Boolean).join('\n') - : null - ); + let user_code; + + if (component.javascript) { + user_code = component.javascript; + } else { + if (!component.ast.instance && !component.ast.module && (filtered_props.length > 0 || component.componentOptions.props)) { + const statements = []; + + if (component.componentOptions.props) statements.push(`let ${component.componentOptions.props} = $$props;`); + if (filtered_props.length > 0) statements.push(`let { ${filtered_props.map(x => x.name).join(', ')} } = $$props;`); - const reactive_store_subscriptions = reactive_stores.length > 0 && reactive_stores + reactive_stores.forEach(({ name }) => { + if (component.compileOptions.dev) { + statements.push(`${component.compileOptions.dev && `@validate_store(${name.slice(1)}, '${name.slice(1)}');`}`); + } + + statements.push(`@subscribe($$self, ${name.slice(1)}, $$value => { ${name} = $$value; $$invalidate('${name}', ${name}); });`); + }); + + user_code = statements.join('\n'); + } + } + + const reactive_store_subscriptions = reactive_stores + .filter(store => { + const variable = component.var_lookup.get(store.name.slice(1)); + return variable.hoistable; + }) .map(({ name }) => deindent` - let ${name}; ${component.compileOptions.dev && `@validate_store(${name.slice(1)}, '${name.slice(1)}');`} - $$self.$$.on_destroy.push(${name.slice(1)}.subscribe($$value => { ${name} = $$value; $$invalidate('${name}', ${name}); })); - `) - .join('\n\n'); + @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.reassigned; + }) + .map(({ name }) => `$$self.$$.on_destroy.push(() => $$unsubscribe_${name.slice(1)}());`); if (has_definition) { const reactive_declarations = component.reactive_declarations.map(d => { @@ -343,8 +385,26 @@ export default function dom( return variable.injected; }); + 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.reassigned) { + return `${$name}, $$unsubscribe_${name} = @noop, $$subscribe_${name} = () => { $$unsubscribe_${name}(); $$unsubscribe_${name} = ${name}.subscribe($$value => { ${$name} = $$value; $$invalidate('${$name}', ${$name}); }) }` + } + + return $name; + }); + builder.addBlock(deindent` function ${definition}(${args.join(', ')}) { + ${reactive_store_declarations.length > 0 && `let ${reactive_store_declarations.join(', ')};`} + + ${reactive_store_subscriptions} + + ${resubscribable_reactive_store_unsubscribers} + ${user_code} ${renderer.slots.size && `let { ${[...renderer.slots].map(name => `$$slot_${sanitize(name)}`).join(', ')}, $$scope } = $$props;`} @@ -353,8 +413,6 @@ export default function dom( ${component.partly_hoisted.length > 0 && component.partly_hoisted.join('\n\n')} - ${reactive_store_subscriptions} - ${set && `$$self.$set = ${set};`} ${reactive_declarations.length > 0 && deindent` diff --git a/src/compile/render-dom/wrappers/Element/index.ts b/src/compile/render-dom/wrappers/Element/index.ts index fea7737671..607c169732 100644 --- a/src/compile/render-dom/wrappers/Element/index.ts +++ b/src/compile/render-dom/wrappers/Element/index.ts @@ -465,7 +465,7 @@ export default class ElementWrapper extends Wrapper { this.renderer.component.partly_hoisted.push(deindent` function ${handler}(${contextual_dependencies.size > 0 ? `{ ${Array.from(contextual_dependencies).join(', ')} }` : ``}) { ${group.bindings.map(b => b.handler.mutation)} - ${Array.from(dependencies).filter(dep => dep[0] !== '$').map(dep => `$$invalidate('${dep}', ${dep});`)} + ${Array.from(dependencies).filter(dep => dep[0] !== '$').map(dep => `${this.renderer.component.invalidate(dep)};`)} } `); @@ -532,7 +532,7 @@ export default class ElementWrapper extends Wrapper { renderer.component.partly_hoisted.push(deindent` function ${name}(${['$$node', 'check'].concat(args).join(', ')}) { ${handler.snippet ? `if ($$node || (!$$node && ${handler.snippet} === check)) ` : ''}${handler.mutation} - $$invalidate('${object}', ${object}); + ${renderer.component.invalidate(object)}; } `); diff --git a/src/compile/render-dom/wrappers/InlineComponent/index.ts b/src/compile/render-dom/wrappers/InlineComponent/index.ts index a974de5687..e30984ccbc 100644 --- a/src/compile/render-dom/wrappers/InlineComponent/index.ts +++ b/src/compile/render-dom/wrappers/InlineComponent/index.ts @@ -266,7 +266,7 @@ export default class InlineComponentWrapper extends Wrapper { component.partly_hoisted.push(deindent` function ${fn}($$component) { ${lhs} = $$component; - ${object && `$$invalidate('${object}', ${object});`} + ${object && component.invalidate(object)} } `); @@ -341,7 +341,7 @@ export default class InlineComponentWrapper extends Wrapper { const body = deindent` function ${name}(${args.join(', ')}) { ${lhs} = value; - return $$invalidate('${dependencies[0]}', ${dependencies[0]}); + return ${component.invalidate(dependencies[0])} } `; diff --git a/src/compile/render-ssr/index.ts b/src/compile/render-ssr/index.ts index 0383e8931c..68e07465d7 100644 --- a/src/compile/render-ssr/index.ts +++ b/src/compile/render-ssr/index.ts @@ -3,6 +3,8 @@ import Component from '../Component'; import { CompileOptions } from '../../interfaces'; import { stringify } from '../../utils/stringify'; import Renderer from './Renderer'; +import { walk } from 'estree-walker'; +import { extractNames } from '../../utils/annotateWithScopes'; export default function ssr( component: Component, @@ -22,29 +24,53 @@ export default function ssr( { code: null, map: null } : component.stylesheet.render(options.filename, true); - let user_code; + const reactive_stores = component.vars.filter(variable => variable.name[0] === '$'); + const reactive_store_values = reactive_stores + .map(({ name }) => { + const assignment = `${name} = @get_store_value(${name.slice(1)});`; + + return component.compileOptions.dev + ? `@validate_store(${name.slice(1)}, '${name.slice(1)}'); ${assignment}` + : assignment; + }); // TODO remove this, just use component.vars everywhere const props = component.vars.filter(variable => !variable.module && variable.export_name && variable.export_name !== component.componentOptions.props_object); + let user_code; + if (component.javascript) { - component.rewrite_props(); + component.rewrite_props(({ name }) => { + const value = `$${name}`; + + const get_store_value = component.helper('get_store_value'); + + let insert = `${value} = ${get_store_value}(${name})`; + if (component.compileOptions.dev) { + const validate_store = component.helper('validate_store'); + insert = `${validate_store}(${name}, '${name}'); ${insert}`; + } + + return insert; + }); + user_code = component.javascript; } else if (!component.ast.instance && !component.ast.module && (props.length > 0 || component.componentOptions.props)) { - user_code = [ - component.componentOptions.props && `let ${component.componentOptions.props} = $$props;`, - props.length > 0 && `let { ${props.map(prop => prop.export_name).join(', ')} } = $$props;` - ].filter(Boolean).join('\n'); - } + const statements = []; - const reactive_stores = component.vars.filter(variable => variable.name[0] === '$'); - const reactive_store_values = reactive_stores.map(({ name }) => { - const assignment = `const ${name} = @get_store_value(${name.slice(1)});`; + if (component.componentOptions.props) statements.push(`let ${component.componentOptions.props} = $$props;`); + if (props.length > 0) statements.push(`let { ${props.map(x => x.name).join(', ')} } = $$props;`); - return component.compileOptions.dev - ? `@validate_store(${name.slice(1)}, '${name.slice(1)}'); ${assignment}` - : assignment; - }); + reactive_stores.forEach(({ name }) => { + if (component.compileOptions.dev) { + statements.push(`${component.compileOptions.dev && `@validate_store(${name.slice(1)}, '${name.slice(1)}');`}`); + } + + statements.push(`${name} = @get_store_value(${name.slice(1)});`); + }); + + user_code = statements.join('\n'); + } // TODO only do this for props with a default value const parent_bindings = component.javascript @@ -83,6 +109,7 @@ export default function ssr( return \`${renderer.code}\`;`; const blocks = [ + reactive_stores.length > 0 && `let ${reactive_stores.map(store => store.name).join(', ')};`, user_code, parent_bindings.join('\n'), css.code && `$$result.css.add(#css);`, diff --git a/src/interfaces.ts b/src/interfaces.ts index 11228a0d55..4ec258c94a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -89,4 +89,5 @@ export interface Var { internal?: boolean; // event handlers, bindings initialised?: boolean; hoistable?: boolean; + subscribable?: boolean; } \ No newline at end of file diff --git a/src/internal/dom.js b/src/internal/dom.js index 32c06dc159..b6b5bae83e 100644 --- a/src/internal/dom.js +++ b/src/internal/dom.js @@ -250,3 +250,9 @@ export function addResizeListener(element, fn) { export function toggleClass(element, name, toggle) { element.classList[toggle ? 'add' : 'remove'](name); } + +export function custom_event(type, detail) { + const e = document.createEvent('CustomEvent'); + e.initCustomEvent(type, false, false, detail); + return e; +} \ No newline at end of file diff --git a/src/internal/lifecycle.js b/src/internal/lifecycle.js index 02fc2161e1..1b9123eb14 100644 --- a/src/internal/lifecycle.js +++ b/src/internal/lifecycle.js @@ -1,3 +1,5 @@ +import { custom_event } from './dom'; + export let current_component; export function set_current_component(component) { @@ -34,7 +36,7 @@ export function createEventDispatcher() { if (callbacks) { // TODO are there situations where events could be dispatched // in a server (non-DOM) environment? - const event = new window.CustomEvent(type, { detail }); + const event = custom_event(type, detail); callbacks.slice().forEach(fn => { fn.call(component, event); }); diff --git a/src/internal/transitions.js b/src/internal/transitions.js index cbbdd7da9c..6b2c5ba01e 100644 --- a/src/internal/transitions.js +++ b/src/internal/transitions.js @@ -1,6 +1,7 @@ import { identity as linear, noop, run_all } from './utils.js'; import { loop } from './loop.js'; import { create_rule, delete_rule } from './style_manager.js'; +import { custom_event } from './dom.js'; let promise; @@ -238,14 +239,14 @@ export function create_bidirectional_transition(node, fn, params, intro) { if (b) tick(0, 1); running_program = init(program, duration); - node.dispatchEvent(new window.CustomEvent(`${running_program.b ? 'intro' : 'outro'}start`)); + node.dispatchEvent(custom_event(`${running_program.b ? 'intro' : 'outro'}start`)); loop(now => { if (pending_program && now > pending_program.start) { running_program = init(pending_program, duration); pending_program = null; - node.dispatchEvent(new window.CustomEvent(`${running_program.b ? 'intro' : 'outro'}start`)); + node.dispatchEvent(custom_event(`${running_program.b ? 'intro' : 'outro'}start`)); if (css) { clear_animation(); @@ -256,7 +257,7 @@ export function create_bidirectional_transition(node, fn, params, intro) { if (running_program) { if (now >= running_program.end) { tick(t = running_program.b, 1 - t); - node.dispatchEvent(new window.CustomEvent(`${running_program.b ? 'intro' : 'outro'}end`)); + node.dispatchEvent(custom_event(`${running_program.b ? 'intro' : 'outro'}end`)); if (!pending_program) { // we're done diff --git a/src/internal/utils.js b/src/internal/utils.js index 2e93efb44b..d6b1bb6380 100644 --- a/src/internal/utils.js +++ b/src/internal/utils.js @@ -47,6 +47,10 @@ export function validate_store(store, name) { } } +export function subscribe(component, store, callback) { + component.$$.on_destroy.push(store.subscribe(callback)); +} + export function create_slot(definition, ctx, fn) { if (definition) { const slot_ctx = get_slot_context(definition, ctx, fn); diff --git a/src/utils/__test__.ts b/src/utils/__test__.ts index 9e7c9f9937..aac99992a1 100644 --- a/src/utils/__test__.ts +++ b/src/utils/__test__.ts @@ -34,6 +34,45 @@ describe('deindent', () => { assert.equal(deindented, `before\n\tline one\n\tline two\nafter`); }); + + it('removes newlines before an empty expression', () => { + const deindented = deindent` + { + some text + + ${null} + }`; + + assert.equal(deindented, `{\n\tsome text\n}`); + }); + + it('removes newlines after an empty expression', () => { + const deindented = deindent` + { + ${null} + + some text + }`; + + assert.equal(deindented, `{\n\tsome text\n}`); + }); + + it('removes newlines around empty expressions', () => { + const deindented = deindent` + { + ${null} + + some text + + ${null} + + some text + + ${null} + }`; + + assert.equal(deindented, `{\n\tsome text\n\n\tsome text\n}`); + }); }); describe('CodeBuilder', () => { diff --git a/src/utils/deindent.ts b/src/utils/deindent.ts index 665f9c3dc1..8c0d596491 100644 --- a/src/utils/deindent.ts +++ b/src/utils/deindent.ts @@ -39,7 +39,7 @@ export default function deindent( current_indentation = get_current_indentation(result); } - return result.trim().replace(/\t+$/gm, ''); + return result.trim().replace(/\t+$/gm, '').replace(/{\n\n/gm, '{\n'); } function get_current_indentation(str: string) { diff --git a/test/css/index.js b/test/css/index.js index 684c310526..be2a10bef1 100644 --- a/test/css/index.js +++ b/test/css/index.js @@ -68,8 +68,8 @@ describe('css', () => { assert.equal(dom.css.code, ssr.css.code); - const dom_warnings = dom.stats.warnings.map(normalize_warning); - const ssr_warnings = ssr.stats.warnings.map(normalize_warning); + const dom_warnings = dom.warnings.map(normalize_warning); + const ssr_warnings = ssr.warnings.map(normalize_warning); assert.deepEqual(dom_warnings, ssr_warnings); assert.deepEqual(dom_warnings.map(normalize_warning), expected_warnings); diff --git a/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/_config.js b/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/_config.js new file mode 100644 index 0000000000..9cfd765cde --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/_config.js @@ -0,0 +1,9 @@ +export default { + html: ` +
42
+ `, + + async test({ assert, component }) { + assert.equal(component.initial_foo, 42); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/main.svelte b/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/main.svelte new file mode 100644 index 0000000000..e98b6002a1 --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-immediate-multiple-vars/main.svelte @@ -0,0 +1,7 @@ + + +{initial_foo}
\ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-immediate/_config.js b/test/runtime/samples/store-auto-subscribe-immediate/_config.js new file mode 100644 index 0000000000..9cfd765cde --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-immediate/_config.js @@ -0,0 +1,9 @@ +export default { + html: ` +42
+ `, + + async test({ assert, component }) { + assert.equal(component.initial_foo, 42); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-immediate/main.svelte b/test/runtime/samples/store-auto-subscribe-immediate/main.svelte new file mode 100644 index 0000000000..b809bb81f6 --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-immediate/main.svelte @@ -0,0 +1,7 @@ + + +{initial_foo}
\ No newline at end of file diff --git a/test/runtime/samples/store-contextual/_config.js b/test/runtime/samples/store-contextual/_config.js new file mode 100644 index 0000000000..c759ede650 --- /dev/null +++ b/test/runtime/samples/store-contextual/_config.js @@ -0,0 +1,76 @@ +import { writable } from '../../../../store.js'; + +const todos = [ + writable({ done: false, text: 'write docs' }), + writable({ done: false, text: 'implement contextual stores' }), + writable({ done: false, text: 'go outside' }) +]; + +export default { + error: `Stores must be declared at the top level of the component (this may change in a future version of Svelte)`, + + props: { + todos + }, + + html: ` + + + + + + `, + + async test({ assert, component, target, window }) { + const inputs = target.querySelectorAll('input'); + const change = new window.MouseEvent('change'); + + inputs[1].checked = true; + await inputs[1].dispatchEvent(change); + + assert.htmlEqual(target.innerHTML, ` + + + + + + `); + + await todos[0].update(todo => ({ done: !todo.done, text: todo.text })); + + assert.htmlEqual(target.innerHTML, ` + + + + + + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-contextual/main.svelte b/test/runtime/samples/store-contextual/main.svelte new file mode 100644 index 0000000000..f8403eded4 --- /dev/null +++ b/test/runtime/samples/store-contextual/main.svelte @@ -0,0 +1,6 @@ +{#each todos as todo} + +{/each} \ No newline at end of file diff --git a/test/runtime/samples/store-resubscribe/_config.js b/test/runtime/samples/store-resubscribe/_config.js new file mode 100644 index 0000000000..fed8caa805 --- /dev/null +++ b/test/runtime/samples/store-resubscribe/_config.js @@ -0,0 +1,36 @@ +export default { + html: ` +