diff --git a/src/compile/Component.ts b/src/compile/Component.ts index 36730ed09e..e2b7011622 100644 --- a/src/compile/Component.ts +++ b/src/compile/Component.ts @@ -428,6 +428,13 @@ export default class Component { imports.push(node); node.specifiers.forEach((specifier: Node) => { + if (specifier.local.name[0] === '$') { + this.error(specifier.local, { + code: 'illegal-declaration', + message: `The $ prefix is reserved, and cannot be used for variable and import names` + }); + } + this.userVars.add(specifier.local.name); this.imported_declarations.add(specifier.local.name); }); @@ -481,7 +488,14 @@ export default class Component { let { scope } = createScopes(script.content); this.module_scope = scope; - // TODO unindent + scope.declarations.forEach((node, name) => { + if (name[0] === '$') { + this.error(node, { + code: 'illegal-declaration', + message: `The $ prefix is reserved, and cannot be used for variable and import names` + }); + } + }); this.extract_imports_and_exports(script.content, this.imports, this.module_exports); remove_indentation(this.code, script.content); @@ -498,6 +512,15 @@ export default class Component { this.instance_scope = instance_scope; this.instance_scope_map = map; + instance_scope.declarations.forEach((node, name) => { + if (name[0] === '$') { + this.error(node, { + code: 'illegal-declaration', + message: `The $ prefix is reserved, and cannot be used for variable and import names` + }); + } + }); + instance_scope.declarations.forEach((node, name) => { this.userVars.add(name); this.declarations.push(name); @@ -790,10 +813,19 @@ export default class Component { assignees.add(getObject(node.argument).name); this.skip(); } else if (isReference(node, parent)) { - const { name } = getObject(node); + const object = getObject(node); + const { name } = object; + if (component.declarations.indexOf(name) !== -1) { dependencies.add(name); + } else if (name[0] === '$') { + component.warn_if_undefined(object, null); + + // cheeky hack + component.template_references.add(name); + dependencies.add(name); } + this.skip(); } }, @@ -882,12 +914,17 @@ export default class Component { } warn_if_undefined(node, template_scope: TemplateScope, allow_implicit?: boolean) { - const { name } = node; + let { name } = node; + + if (name[0] === '$') { + name = name.slice(1); + this.has_reactive_assignments = true; + } if (allow_implicit && !this.instance_script) return; if (this.instance_scope && this.instance_scope.declarations.has(name)) return; if (this.module_scope && this.module_scope.declarations.has(name)) return; - if (template_scope.names.has(name)) return; + if (template_scope && template_scope.names.has(name)) return; if (globalWhitelist.has(name)) return; this.warn(node, { diff --git a/src/compile/nodes/shared/Expression.ts b/src/compile/nodes/shared/Expression.ts index c7a3d5de91..e3e2fb975e 100644 --- a/src/compile/nodes/shared/Expression.ts +++ b/src/compile/nodes/shared/Expression.ts @@ -118,7 +118,7 @@ export default class Expression { // conditions — it doesn't apply if the dependency is inside a // function, and it only applies if the dependency is writable if (component.instance_script) { - if (component.writable_declarations.has(name)) { + if (component.writable_declarations.has(name) || name[0] === '$') { dynamic_dependencies.add(name); } } else { diff --git a/src/compile/render-dom/index.ts b/src/compile/render-dom/index.ts index ffc0634e02..305505df24 100644 --- a/src/compile/render-dom/index.ts +++ b/src/compile/render-dom/index.ts @@ -257,11 +257,15 @@ export default function dom( return true; }); + const reactive_stores = Array.from(component.template_references).filter(n => n[0] === '$'); + filtered_declarations.push(...reactive_stores); + const has_definition = ( component.javascript || filtered_props.length > 0 || component.partly_hoisted.length > 0 || filtered_declarations.length > 0 || + reactive_stores.length > 0 || component.reactive_declarations.length > 0 ); @@ -280,6 +284,14 @@ export default function dom( : null ); + const reactive_store_subscriptions = reactive_stores.length > 0 && reactive_stores + .map(name => deindent` + let ${name}; + ${component.options.dev && `@validate_store(${name.slice(1)}, '${name.slice(1)}');`} + $$self.$$.on_destroy.push(${name.slice(1)}.subscribe($$value => { ${name} = $$value; $$make_dirty('${name}'); })); + `) + .join('\n\n'); + if (has_definition) { builder.addBlock(deindent` function ${definition}(${args.join(', ')}) { @@ -287,6 +299,8 @@ export default function dom( ${component.partly_hoisted.length > 0 && component.partly_hoisted.join('\n\n')} + ${reactive_store_subscriptions} + ${filtered_declarations.length > 0 && `$$self.$$.get = () => (${stringifyProps(filtered_declarations)});`} ${set && `$$self.$$.set = ${set};`} diff --git a/src/compile/render-ssr/index.ts b/src/compile/render-ssr/index.ts index 28d47475a5..51353d82f0 100644 --- a/src/compile/render-ssr/index.ts +++ b/src/compile/render-ssr/index.ts @@ -36,6 +36,15 @@ export default function ssr( user_code = `let { ${props.join(', ')} } = $$props;` } + const reactive_stores = Array.from(component.template_references).filter(n => n[0] === '$'); + const reactive_store_values = reactive_stores.map(name => { + const assignment = `const ${name} = @get_store_value(${name.slice(1)});`; + + return component.options.dev + ? `@validate_store(${name.slice(1)}, '${name.slice(1)}'); ${assignment}` + : assignment; + }); + // TODO only do this for props with a default value const parent_bindings = component.javascript ? component.props.map(prop => { @@ -51,6 +60,8 @@ export default function ssr( do { $$settled = true; + ${reactive_store_values} + ${component.reactive_declarations.map(d => d.snippet)} $$rendered = \`${renderer.code}\`; @@ -59,6 +70,8 @@ export default function ssr( return $$rendered; ` : deindent` + ${reactive_store_values} + ${component.reactive_declarations.map(d => d.snippet)} return \`${renderer.code}\`;`; diff --git a/src/internal/ssr.js b/src/internal/ssr.js index a6eb13107b..170f841ec6 100644 --- a/src/internal/ssr.js +++ b/src/internal/ssr.js @@ -97,4 +97,10 @@ export function create_ssr_component($$render) { $$render }; +} + +export function get_store_value(store) { + let value; + store.subscribe(_ => value = _)(); + return value; } \ No newline at end of file diff --git a/src/internal/utils.js b/src/internal/utils.js index 55feb743e2..420a306508 100644 --- a/src/internal/utils.js +++ b/src/internal/utils.js @@ -55,4 +55,10 @@ export function safe_not_equal(a, b) { export function not_equal(a, b) { return a != a ? b == b : a !== b; +} + +export function validate_store(store, name) { + if (!store || typeof store.subscribe !== 'function') { + throw new Error(`'${name}' is not a store with a 'subscribe' method`); + } } \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-in-each/_config.js b/test/runtime/samples/store-auto-subscribe-in-each/_config.js new file mode 100644 index 0000000000..969f9a9b74 --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-in-each/_config.js @@ -0,0 +1,40 @@ +import { writable } from '../../../../store.js'; + +export default { + skip: true, + + props: { + things: [ + writable('a'), + writable('b'), + writable('c') + ] + }, + + html: ` + + + + `, + + async test({ assert, component, target, window }) { + const buttons = target.querySelectorAll('button'); + const click = new window.MouseEvent('click'); + + await buttons[1].dispatchEvent(click); + + assert.htmlEqual(target.innerHTML, ` + + + + `); + + await component.things[1].set('d'); + + assert.htmlEqual(target.innerHTML, ` + + + + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-in-each/main.html b/test/runtime/samples/store-auto-subscribe-in-each/main.html new file mode 100644 index 0000000000..baf120838c --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-in-each/main.html @@ -0,0 +1,7 @@ + + +{#each things as thing} + +{/each} \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/_config.js b/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/_config.js new file mode 100644 index 0000000000..b2a5a8dba7 --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/_config.js @@ -0,0 +1,28 @@ +import { writable } from '../../../../store.js'; + +export default { + props: { + count: writable(0) + }, + + html: ` + + `, + + async test({ assert, component, target, window }) { + const button = target.querySelector('button'); + const click = new window.MouseEvent('click'); + + await button.dispatchEvent(click); + + assert.htmlEqual(target.innerHTML, ` + + `); + + await component.count.set(42); + + assert.htmlEqual(target.innerHTML, ` + + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/main.html b/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/main.html new file mode 100644 index 0000000000..3233d38d2d --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe-in-reactive-declaration/main.html @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe/_config.js b/test/runtime/samples/store-auto-subscribe/_config.js new file mode 100644 index 0000000000..b8203f2a68 --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe/_config.js @@ -0,0 +1,28 @@ +import { writable } from '../../../../store.js'; + +export default { + props: { + count: writable(0) + }, + + html: ` + + `, + + async test({ assert, component, target, window }) { + const button = target.querySelector('button'); + const click = new window.MouseEvent('click'); + + await button.dispatchEvent(click); + + assert.htmlEqual(target.innerHTML, ` + + `); + + await component.count.set(42); + + assert.htmlEqual(target.innerHTML, ` + + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-auto-subscribe/main.html b/test/runtime/samples/store-auto-subscribe/main.html new file mode 100644 index 0000000000..849de85acf --- /dev/null +++ b/test/runtime/samples/store-auto-subscribe/main.html @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/store-dev-mode-error/_config.js b/test/runtime/samples/store-dev-mode-error/_config.js new file mode 100644 index 0000000000..70b24337fd --- /dev/null +++ b/test/runtime/samples/store-dev-mode-error/_config.js @@ -0,0 +1,11 @@ +export default { + compileOptions: { + dev: true + }, + + props: { + count: 0 + }, + + error: `'count' is not a store with a 'subscribe' method` +}; \ No newline at end of file diff --git a/test/runtime/samples/store-dev-mode-error/main.html b/test/runtime/samples/store-dev-mode-error/main.html new file mode 100644 index 0000000000..849de85acf --- /dev/null +++ b/test/runtime/samples/store-dev-mode-error/main.html @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/store-prevent-user-declarations/_config.js b/test/runtime/samples/store-prevent-user-declarations/_config.js new file mode 100644 index 0000000000..312348eba4 --- /dev/null +++ b/test/runtime/samples/store-prevent-user-declarations/_config.js @@ -0,0 +1,9 @@ +import { writable } from '../../../../store.js'; + +export default { + props: { + count: writable(0) + }, + + error: `The $ prefix is reserved, and cannot be used for variable and import names` +}; \ No newline at end of file diff --git a/test/runtime/samples/store-prevent-user-declarations/main.html b/test/runtime/samples/store-prevent-user-declarations/main.html new file mode 100644 index 0000000000..78bed1d520 --- /dev/null +++ b/test/runtime/samples/store-prevent-user-declarations/main.html @@ -0,0 +1,6 @@ + + + \ No newline at end of file