diff --git a/.eslintignore b/.eslintignore index bc31435419..4a113378ce 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,6 @@ src/shared shared.js +store.js test/test.js test/setup.js **/_actual.js diff --git a/package.json b/package.json index 6bf9fae197..7b1937020d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "compiler", "ssr", "shared.js", + "store.js", "README.md" ], "scripts": { diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index a8775d41b2..99f7597276 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -536,6 +536,9 @@ export default class Generator { (param: Node) => param.type === 'AssignmentPattern' ? param.left.name : param.name ); + deps.forEach(dep => { + this.expectedProperties.add(dep); + }); dependencies.set(key, deps); }); diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts index f6c007c79d..c6d9e94df2 100644 --- a/src/generators/dom/index.ts +++ b/src/generators/dom/index.ts @@ -184,15 +184,23 @@ export default function dom( const debugName = `<${generator.customElement ? generator.tag : name}>`; // generate initial state object - const globals = Array.from(generator.expectedProperties).filter(prop => globalWhitelist.has(prop)); + const expectedProperties = Array.from(generator.expectedProperties); + const globals = expectedProperties.filter(prop => globalWhitelist.has(prop)); + const storeProps = options.store ? expectedProperties.filter(prop => prop[0] === '$') : []; + const initialState = []; + if (globals.length > 0) { initialState.push(`{ ${globals.map(prop => `${prop} : ${prop}`).join(', ')} }`); } + if (storeProps.length > 0) { + initialState.push(`this.store._init([${storeProps.map(prop => `"${prop.slice(1)}"`)}])`); + } + if (templateProperties.data) { initialState.push(`%data()`); - } else if (globals.length === 0) { + } else if (globals.length === 0 && storeProps.length === 0) { initialState.push('{}'); } @@ -205,6 +213,7 @@ export default function dom( @init(this, options); ${generator.usesRefs && `this.refs = {};`} this._state = @assign(${initialState.join(', ')}); + ${storeProps.length > 0 && `this.store._add(this, [${storeProps.map(prop => `"${prop.slice(1)}"`)}]);`} ${generator.metaBindings} ${computations.length && `this._recompute({ ${Array.from(computationDeps).map(dep => `${dep}: 1`).join(', ')} }, this._state);`} ${options.dev && @@ -215,7 +224,11 @@ export default function dom( ${generator.bindingGroups.length && `this._bindingGroups = [${Array(generator.bindingGroups.length).fill('[]').join(', ')}];`} - ${templateProperties.ondestroy && `this._handlers.destroy = [%ondestroy]`} + ${(templateProperties.ondestroy || storeProps.length) && ( + `this._handlers.destroy = [${ + [templateProperties.ondestroy && `%ondestroy`, storeProps.length && `@removeFromStore`].filter(Boolean).join(', ') + }];` + )} ${generator.slots.size && `this._slotted = options.slots || {};`} diff --git a/src/generators/dom/visitors/Element/addBindings.ts b/src/generators/dom/visitors/Element/addBindings.ts index a2433abf93..2e41cd1b19 100644 --- a/src/generators/dom/visitors/Element/addBindings.ts +++ b/src/generators/dom/visitors/Element/addBindings.ts @@ -195,13 +195,19 @@ export default function addBindings( const usesContext = group.bindings.some(binding => binding.handler.usesContext); const usesState = group.bindings.some(binding => binding.handler.usesState); + const usesStore = group.bindings.some(binding => binding.handler.usesStore); const mutations = group.bindings.map(binding => binding.handler.mutation).filter(Boolean).join('\n'); const props = new Set(); + const storeProps = new Set(); group.bindings.forEach(binding => { binding.handler.props.forEach(prop => { props.add(prop); }); + + binding.handler.storeProps.forEach(prop => { + storeProps.add(prop); + }); }); // TODO use stringifyProps here, once indenting is fixed // media bindings — awkward special case. The native timeupdate events @@ -222,9 +228,11 @@ export default function addBindings( } ${usesContext && `var context = ${node.var}._svelte;`} ${usesState && `var state = #component.get();`} + ${usesStore && `var $ = #component.store.get();`} ${needsLock && `${lock} = true;`} ${mutations.length > 0 && mutations} - #component.set({ ${Array.from(props).join(', ')} }); + ${props.size > 0 && `#component.set({ ${Array.from(props).join(', ')} });`} + ${storeProps.size > 0 && `#component.store.set({ ${Array.from(storeProps).join(', ')} });`} ${needsLock && `${lock} = false;`} } `); @@ -307,6 +315,13 @@ function getEventHandler( dependencies: string[], value: string, ) { + let storeDependencies = []; + + if (generator.options.store) { + storeDependencies = dependencies.filter(prop => prop[0] === '$').map(prop => prop.slice(1)); + dependencies = dependencies.filter(prop => prop[0] !== '$'); + } + if (block.contexts.has(name)) { const tail = attribute.value.type === 'MemberExpression' ? getTailSnippet(attribute.value) @@ -318,8 +333,10 @@ function getEventHandler( return { usesContext: true, usesState: true, + usesStore: storeDependencies.length > 0, mutation: `${list}[${index}]${tail} = ${value};`, - props: dependencies.map(prop => `${prop}: state.${prop}`) + props: dependencies.map(prop => `${prop}: state.${prop}`), + storeProps: storeDependencies.map(prop => `${prop}: $.${prop}`) }; } @@ -336,16 +353,31 @@ function getEventHandler( return { usesContext: false, usesState: true, + usesStore: storeDependencies.length > 0, mutation: `${snippet} = ${value}`, - props: dependencies.map((prop: string) => `${prop}: state.${prop}`) + props: dependencies.map((prop: string) => `${prop}: state.${prop}`), + storeProps: storeDependencies.map(prop => `${prop}: $.${prop}`) }; } + let props; + let storeProps; + + if (generator.options.store && name[0] === '$') { + props = []; + storeProps = [`${name.slice(1)}: ${value}`]; + } else { + props = [`${name}: ${value}`]; + storeProps = []; + } + return { usesContext: false, usesState: false, + usesStore: false, mutation: null, - props: [`${name}: ${value}`] + props, + storeProps }; } @@ -393,4 +425,4 @@ function isComputed(node: Node) { } return false; -} +} \ No newline at end of file diff --git a/src/generators/server-side-rendering/index.ts b/src/generators/server-side-rendering/index.ts index 2accc2bc7e..c6741cb958 100644 --- a/src/generators/server-side-rendering/index.ts +++ b/src/generators/server-side-rendering/index.ts @@ -73,16 +73,22 @@ export default function ssr( generator.stylesheet.render(options.filename, true); // generate initial state object - // TODO this doesn't work, because expectedProperties isn't populated - const globals = Array.from(generator.expectedProperties).filter(prop => globalWhitelist.has(prop)); + const expectedProperties = Array.from(generator.expectedProperties); + const globals = expectedProperties.filter(prop => globalWhitelist.has(prop)); + const storeProps = options.store ? expectedProperties.filter(prop => prop[0] === '$') : []; + const initialState = []; if (globals.length > 0) { initialState.push(`{ ${globals.map(prop => `${prop} : ${prop}`).join(', ')} }`); } + if (storeProps.length > 0) { + initialState.push(`options.store._init([${storeProps.map(prop => `"${prop.slice(1)}"`)}])`); + } + if (templateProperties.data) { initialState.push(`%data()`); - } else if (globals.length === 0) { + } else if (globals.length === 0 && storeProps.length === 0) { initialState.push('{}'); } @@ -99,7 +105,7 @@ export default function ssr( return ${templateProperties.data ? `%data()` : `{}`}; }; - ${name}.render = function(state, options) { + ${name}.render = function(state, options = {}) { state = Object.assign(${initialState.join(', ')}); ${computations.map( diff --git a/src/generators/server-side-rendering/visitors/Component.ts b/src/generators/server-side-rendering/visitors/Component.ts index b582dc13d9..74c7e239e4 100644 --- a/src/generators/server-side-rendering/visitors/Component.ts +++ b/src/generators/server-side-rendering/visitors/Component.ts @@ -79,6 +79,11 @@ export default function visitComponent( let open = `\${${expression}.render({${props}}`; + const options = []; + if (generator.options.store) { + options.push(`store: options.store`); + } + if (node.children.length) { const appendTarget: AppendTarget = { slots: { default: '' }, @@ -95,11 +100,15 @@ export default function visitComponent( .map(name => `${name}: () => \`${appendTarget.slots[name]}\``) .join(', '); - open += `, { slotted: { ${slotted} } }`; + options.push(`slotted: { ${slotted} }`); generator.appendTargets.pop(); } + if (options.length) { + open += `, { ${options.join(', ')} }`; + } + generator.append(open); generator.append(')}'); } diff --git a/src/interfaces.ts b/src/interfaces.ts index fff52e8da4..3a9280df3c 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -56,6 +56,7 @@ export interface CompileOptions { legacy?: boolean; customElement?: CustomElementOptions | true; css?: boolean; + store?: boolean; onerror?: (error: Error) => void; onwarn?: (warning: Warning) => void; diff --git a/src/server-side-rendering/register.js b/src/server-side-rendering/register.js index 254c1e4419..bb4ea61e7b 100644 --- a/src/server-side-rendering/register.js +++ b/src/server-side-rendering/register.js @@ -2,16 +2,22 @@ import * as fs from 'fs'; import * as path from 'path'; import { compile } from '../index.ts'; +const compileOptions = {}; + function capitalise(name) { return name[0].toUpperCase() + name.slice(1); } export default function register(options) { const { extensions } = options; + if (extensions) { _deregister('.html'); extensions.forEach(_register); } + + // TODO make this the default and remove in v2 + if ('store' in options) compileOptions.store = options.store; } function _deregister(extension) { @@ -20,13 +26,15 @@ function _deregister(extension) { function _register(extension) { require.extensions[extension] = function(module, filename) { - const {code} = compile(fs.readFileSync(filename, 'utf-8'), { + const options = Object.assign({}, compileOptions, { filename, name: capitalise(path.basename(filename) .replace(new RegExp(`${extension.replace('.', '\\.')}$`), '')), - generate: 'ssr', + generate: 'ssr' }); + const {code} = compile(fs.readFileSync(filename, 'utf-8'), options); + return module._compile(code, filename); }; } diff --git a/src/shared/index.js b/src/shared/index.js index f9d0f91998..9825972b82 100644 --- a/src/shared/index.js +++ b/src/shared/index.js @@ -65,12 +65,13 @@ export function get(key) { } export function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } export function observe(key, callback, options) { @@ -187,6 +188,10 @@ export function _unmount() { this._fragment.u(); } +export function removeFromStore() { + this.store._remove(this); +} + export var proto = { destroy: destroy, get: get, diff --git a/src/validate/html/validateEventHandler.ts b/src/validate/html/validateEventHandler.ts index 2339f801d9..8e8a6122b3 100644 --- a/src/validate/html/validateEventHandler.ts +++ b/src/validate/html/validateEventHandler.ts @@ -1,6 +1,6 @@ import flattenReference from '../../utils/flattenReference'; import list from '../../utils/list'; -import { Validator } from '../index'; +import validate, { Validator } from '../index'; import validCalleeObjects from '../../utils/validCalleeObjects'; import { Node } from '../../interfaces'; @@ -28,6 +28,13 @@ export default function validateEventHandlerCallee( return; } + if (name === 'store' && attribute.expression.callee.type === 'MemberExpression') { + if (!validator.options.store) { + validator.warn('compile with `store: true` in order to call store methods', attribute.expression.start); + } + return; + } + if ( (callee.type === 'Identifier' && validBuiltins.has(callee.name)) || validator.methods.has(callee.name) @@ -35,6 +42,7 @@ export default function validateEventHandlerCallee( return; const validCallees = ['this.*', 'event.*', 'options.*', 'console.*'].concat( + validator.options.store ? 'store.*' : [], Array.from(validBuiltins), Array.from(validator.methods.keys()) ); diff --git a/src/validate/index.ts b/src/validate/index.ts index 17c3e83be9..5ed4d58bd9 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -22,6 +22,7 @@ export class Validator { readonly source: string; readonly filename: string; + options: CompileOptions; onwarn: ({}) => void; locator?: (pos: number) => Location; @@ -37,8 +38,8 @@ export class Validator { constructor(parsed: Parsed, source: string, options: CompileOptions) { this.source = source; this.filename = options.filename; - this.onwarn = options.onwarn; + this.options = options; this.namespace = null; this.defaultExport = null; @@ -78,7 +79,7 @@ export default function validate( stylesheet: Stylesheet, options: CompileOptions ) { - const { onwarn, onerror, name, filename } = options; + const { onwarn, onerror, name, filename, store } = options; try { if (name && !/^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(name)) { @@ -99,6 +100,7 @@ export default function validate( onwarn, name, filename, + store }); if (parsed.js) { diff --git a/store.js b/store.js new file mode 100644 index 0000000000..a1d6dc4d2e --- /dev/null +++ b/store.js @@ -0,0 +1,153 @@ +import { + assign, + blankObject, + differs, + dispatchObservers, + get, + observe +} from './shared.js'; + +function Store(state) { + this._observers = { pre: blankObject(), post: blankObject() }; + this._changeHandlers = []; + this._dependents = []; + + this._computed = blankObject(); + this._sortedComputedProperties = []; + + this._state = assign({}, state); +} + +assign(Store.prototype, { + _add: function(component, props) { + this._dependents.push({ + component: component, + props: props + }); + }, + + _init: function(props) { + var state = {}; + for (var i = 0; i < props.length; i += 1) { + var prop = props[i]; + state['$' + prop] = this._state[prop]; + } + return state; + }, + + _remove: function(component) { + var i = this._dependents.length; + while (i--) { + if (this._dependents[i].component === component) { + this._dependents.splice(i, 1); + return; + } + } + }, + + _sortComputedProperties: function() { + var computed = this._computed; + var sorted = this._sortedComputedProperties = []; + var visited = blankObject(); + + function visit(key) { + if (visited[key]) return; + var c = computed[key]; + + if (c) { + c.deps.forEach(visit); + sorted.push(c); + } + } + + for (var key in this._computed) visit(key); + }, + + compute: function(key, deps, fn) { + var store = this; + var value; + + var c = { + deps: deps, + update: function(state, changed, dirty) { + var values = deps.map(function(dep) { + if (dep in changed) dirty = true; + return state[dep]; + }); + + if (dirty) { + var newValue = fn.apply(null, values); + if (differs(newValue, value)) { + value = newValue; + changed[key] = true; + state[key] = value; + } + } + } + }; + + c.update(this._state, {}, true); + + this._computed[key] = c; + this._sortComputedProperties(); + }, + + get: get, + + observe: observe, + + onchange: function(callback) { + this._changeHandlers.push(callback); + return { + cancel: function() { + var index = this._changeHandlers.indexOf(callback); + if (~index) this._changeHandlers.splice(index, 1); + } + }; + }, + + set: function(newState) { + var oldState = this._state, + changed = this._changed = {}, + dirty = false; + + for (var key in newState) { + if (this._computed[key]) throw new Error("'" + key + "' is a read-only property"); + if (differs(newState[key], oldState[key])) changed[key] = dirty = true; + } + if (!dirty) return; + + this._state = assign({}, oldState, newState); + + for (var i = 0; i < this._sortedComputedProperties.length; i += 1) { + this._sortedComputedProperties[i].update(this._state, changed); + } + + for (var i = 0; i < this._changeHandlers.length; i += 1) { + this._changeHandlers[i](this._state, changed); + } + + dispatchObservers(this, this._observers.pre, changed, this._state, oldState); + + var dependents = this._dependents.slice(); // guard against mutations + for (var i = 0; i < dependents.length; i += 1) { + var dependent = dependents[i]; + var componentState = {}; + dirty = false; + + for (var j = 0; j < dependent.props.length; j += 1) { + var prop = dependent.props[j]; + if (prop in changed) { + componentState['$' + prop] = this._state[prop]; + dirty = true; + } + } + + if (dirty) dependent.component.set(componentState); + } + + dispatchObservers(this, this._observers.post, changed, this._state, oldState); + } +}); + +export { Store }; \ No newline at end of file diff --git a/test/js/samples/collapses-text-around-comments/expected-bundle.js b/test/js/samples/collapses-text-around-comments/expected-bundle.js index a5776bc195..c54a4508e7 100644 --- a/test/js/samples/collapses-text-around-comments/expected-bundle.js +++ b/test/js/samples/collapses-text-around-comments/expected-bundle.js @@ -91,12 +91,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/component-static/expected-bundle.js b/test/js/samples/component-static/expected-bundle.js index 94f69ca2ca..27d4a5d5df 100644 --- a/test/js/samples/component-static/expected-bundle.js +++ b/test/js/samples/component-static/expected-bundle.js @@ -67,12 +67,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/computed-collapsed-if/expected-bundle.js b/test/js/samples/computed-collapsed-if/expected-bundle.js index c378d78026..9129d64ebf 100644 --- a/test/js/samples/computed-collapsed-if/expected-bundle.js +++ b/test/js/samples/computed-collapsed-if/expected-bundle.js @@ -67,12 +67,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/css-media-query/expected-bundle.js b/test/js/samples/css-media-query/expected-bundle.js index 11ffa87f21..3eb06a7baa 100644 --- a/test/js/samples/css-media-query/expected-bundle.js +++ b/test/js/samples/css-media-query/expected-bundle.js @@ -87,12 +87,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/css-shadow-dom-keyframes/expected-bundle.js b/test/js/samples/css-shadow-dom-keyframes/expected-bundle.js index 86edac7465..a8a14c254a 100644 --- a/test/js/samples/css-shadow-dom-keyframes/expected-bundle.js +++ b/test/js/samples/css-shadow-dom-keyframes/expected-bundle.js @@ -79,12 +79,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/do-use-dataset/expected-bundle.js b/test/js/samples/do-use-dataset/expected-bundle.js index 42de89fd93..1baf806a52 100644 --- a/test/js/samples/do-use-dataset/expected-bundle.js +++ b/test/js/samples/do-use-dataset/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/dont-use-dataset-in-legacy/expected-bundle.js b/test/js/samples/dont-use-dataset-in-legacy/expected-bundle.js index 2d25e23a10..3ebad459d3 100644 --- a/test/js/samples/dont-use-dataset-in-legacy/expected-bundle.js +++ b/test/js/samples/dont-use-dataset-in-legacy/expected-bundle.js @@ -87,12 +87,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/each-block-changed-check/expected-bundle.js b/test/js/samples/each-block-changed-check/expected-bundle.js index c9723db616..dae7830ecb 100644 --- a/test/js/samples/each-block-changed-check/expected-bundle.js +++ b/test/js/samples/each-block-changed-check/expected-bundle.js @@ -99,12 +99,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/event-handlers-custom/expected-bundle.js b/test/js/samples/event-handlers-custom/expected-bundle.js index 46c109100a..b1974ae869 100644 --- a/test/js/samples/event-handlers-custom/expected-bundle.js +++ b/test/js/samples/event-handlers-custom/expected-bundle.js @@ -79,12 +79,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/if-block-no-update/expected-bundle.js b/test/js/samples/if-block-no-update/expected-bundle.js index 0277095a83..12822a4ed3 100644 --- a/test/js/samples/if-block-no-update/expected-bundle.js +++ b/test/js/samples/if-block-no-update/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/if-block-simple/expected-bundle.js b/test/js/samples/if-block-simple/expected-bundle.js index 40acbe3959..0985260579 100644 --- a/test/js/samples/if-block-simple/expected-bundle.js +++ b/test/js/samples/if-block-simple/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/inline-style-optimized-multiple/expected-bundle.js b/test/js/samples/inline-style-optimized-multiple/expected-bundle.js index eff6f53cfa..6260246cc0 100644 --- a/test/js/samples/inline-style-optimized-multiple/expected-bundle.js +++ b/test/js/samples/inline-style-optimized-multiple/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/inline-style-optimized-url/expected-bundle.js b/test/js/samples/inline-style-optimized-url/expected-bundle.js index 3a126946ad..921d377bf9 100644 --- a/test/js/samples/inline-style-optimized-url/expected-bundle.js +++ b/test/js/samples/inline-style-optimized-url/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/inline-style-optimized/expected-bundle.js b/test/js/samples/inline-style-optimized/expected-bundle.js index 0c127a6391..8ddc55d215 100644 --- a/test/js/samples/inline-style-optimized/expected-bundle.js +++ b/test/js/samples/inline-style-optimized/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/inline-style-unoptimized/expected-bundle.js b/test/js/samples/inline-style-unoptimized/expected-bundle.js index d6f545111a..c3ca9e6957 100644 --- a/test/js/samples/inline-style-unoptimized/expected-bundle.js +++ b/test/js/samples/inline-style-unoptimized/expected-bundle.js @@ -83,12 +83,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/input-without-blowback-guard/expected-bundle.js b/test/js/samples/input-without-blowback-guard/expected-bundle.js index 1918f9f6d1..a7a7d25e0c 100644 --- a/test/js/samples/input-without-blowback-guard/expected-bundle.js +++ b/test/js/samples/input-without-blowback-guard/expected-bundle.js @@ -87,12 +87,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/legacy-input-type/expected-bundle.js b/test/js/samples/legacy-input-type/expected-bundle.js index 7211738f45..54a4c3c02d 100644 --- a/test/js/samples/legacy-input-type/expected-bundle.js +++ b/test/js/samples/legacy-input-type/expected-bundle.js @@ -85,12 +85,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/legacy-quote-class/expected-bundle.js b/test/js/samples/legacy-quote-class/expected-bundle.js index 1575bf4064..c6f7bb8405 100644 --- a/test/js/samples/legacy-quote-class/expected-bundle.js +++ b/test/js/samples/legacy-quote-class/expected-bundle.js @@ -102,12 +102,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/media-bindings/expected-bundle.js b/test/js/samples/media-bindings/expected-bundle.js index 80545b9402..bdac2a8f2a 100644 --- a/test/js/samples/media-bindings/expected-bundle.js +++ b/test/js/samples/media-bindings/expected-bundle.js @@ -95,12 +95,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/non-imported-component/expected-bundle.js b/test/js/samples/non-imported-component/expected-bundle.js index 575be613fa..fd201d65b3 100644 --- a/test/js/samples/non-imported-component/expected-bundle.js +++ b/test/js/samples/non-imported-component/expected-bundle.js @@ -81,12 +81,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js b/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js index f1e701bd04..364ee2901d 100644 --- a/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js +++ b/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js @@ -67,12 +67,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/onrender-onteardown-rewritten/expected.js b/test/js/samples/onrender-onteardown-rewritten/expected.js index 19b85a62e0..ae6046dddb 100644 --- a/test/js/samples/onrender-onteardown-rewritten/expected.js +++ b/test/js/samples/onrender-onteardown-rewritten/expected.js @@ -24,7 +24,7 @@ function SvelteComponent(options) { init(this, options); this._state = assign({}, options.data); - this._handlers.destroy = [ondestroy] + this._handlers.destroy = [ondestroy]; var _oncreate = oncreate.bind(this); diff --git a/test/js/samples/setup-method/expected-bundle.js b/test/js/samples/setup-method/expected-bundle.js index 0c545ea70c..df267db975 100644 --- a/test/js/samples/setup-method/expected-bundle.js +++ b/test/js/samples/setup-method/expected-bundle.js @@ -67,12 +67,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/ssr-no-oncreate-etc/expected-bundle.js b/test/js/samples/ssr-no-oncreate-etc/expected-bundle.js index 9a466e06ae..1dfa59b423 100644 --- a/test/js/samples/ssr-no-oncreate-etc/expected-bundle.js +++ b/test/js/samples/ssr-no-oncreate-etc/expected-bundle.js @@ -4,7 +4,7 @@ SvelteComponent.data = function() { return {}; }; -SvelteComponent.render = function(state, options) { +SvelteComponent.render = function(state, options = {}) { state = Object.assign({}, state); return ``.trim(); diff --git a/test/js/samples/ssr-no-oncreate-etc/expected.js b/test/js/samples/ssr-no-oncreate-etc/expected.js index 51c10c7656..c64b3877ab 100644 --- a/test/js/samples/ssr-no-oncreate-etc/expected.js +++ b/test/js/samples/ssr-no-oncreate-etc/expected.js @@ -6,7 +6,7 @@ SvelteComponent.data = function() { return {}; }; -SvelteComponent.render = function(state, options) { +SvelteComponent.render = function(state, options = {}) { state = Object.assign({}, state); return ``.trim(); diff --git a/test/js/samples/use-elements-as-anchors/expected-bundle.js b/test/js/samples/use-elements-as-anchors/expected-bundle.js index 8805cc3847..272e039772 100644 --- a/test/js/samples/use-elements-as-anchors/expected-bundle.js +++ b/test/js/samples/use-elements-as-anchors/expected-bundle.js @@ -91,12 +91,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/js/samples/window-binding-scroll/expected-bundle.js b/test/js/samples/window-binding-scroll/expected-bundle.js index 6c357ef732..3a11deb1b6 100644 --- a/test/js/samples/window-binding-scroll/expected-bundle.js +++ b/test/js/samples/window-binding-scroll/expected-bundle.js @@ -87,12 +87,13 @@ function get(key) { } function init(component, options) { - component.options = options; - component._observers = { pre: blankObject(), post: blankObject() }; component._handlers = blankObject(); component._root = options._root || component; component._bind = options._bind; + + component.options = options; + component.store = component._root.options.store; } function observe(key, callback, options) { diff --git a/test/runtime/index.js b/test/runtime/index.js index 122fe645b6..a82f5b6975 100644 --- a/test/runtime/index.js +++ b/test/runtime/index.js @@ -63,6 +63,7 @@ describe("runtime", () => { compileOptions.shared = shared; compileOptions.hydratable = hydrate; compileOptions.dev = config.dev; + compileOptions.store = !!config.store; // check that no ES2015+ syntax slipped in if (!config.allowES2015) { @@ -88,7 +89,7 @@ describe("runtime", () => { } } catch (err) { failed.add(dir); - showOutput(cwd, { shared }, svelte); // eslint-disable-line no-console + showOutput(cwd, { shared, store: !!compileOptions.store }, svelte); // eslint-disable-line no-console throw err; } } @@ -134,7 +135,7 @@ describe("runtime", () => { try { SvelteComponent = require(`./samples/${dir}/main.html`); } catch (err) { - showOutput(cwd, { shared, hydratable: hydrate }, svelte); // eslint-disable-line no-console + showOutput(cwd, { shared, hydratable: hydrate, store: !!compileOptions.store }, svelte); // eslint-disable-line no-console throw err; } @@ -154,7 +155,8 @@ describe("runtime", () => { const options = Object.assign({}, { target, hydrate, - data: config.data + data: config.data, + store: config.store }, config.options || {}); const component = new SvelteComponent(options); @@ -188,12 +190,12 @@ describe("runtime", () => { config.error(assert, err); } else { failed.add(dir); - showOutput(cwd, { shared, hydratable: hydrate }, svelte); // eslint-disable-line no-console + showOutput(cwd, { shared, hydratable: hydrate, store: !!compileOptions.store }, svelte); // eslint-disable-line no-console throw err; } } - if (config.show) showOutput(cwd, { shared, hydratable: hydrate }, svelte); + if (config.show) showOutput(cwd, { shared, hydratable: hydrate, store: !!compileOptions.store }, svelte); }); } diff --git a/test/runtime/samples/dev-warning-readonly-computed/_config.js b/test/runtime/samples/dev-warning-readonly-computed/_config.js index 2706b9610a..95d4ea15c4 100644 --- a/test/runtime/samples/dev-warning-readonly-computed/_config.js +++ b/test/runtime/samples/dev-warning-readonly-computed/_config.js @@ -1,6 +1,10 @@ export default { dev: true, + data: { + a: 42 + }, + test ( assert, component ) { try { component.set({ foo: 1 }); diff --git a/test/runtime/samples/set-clones-input/_config.js b/test/runtime/samples/set-clones-input/_config.js index af04f4b73e..8f365f6330 100644 --- a/test/runtime/samples/set-clones-input/_config.js +++ b/test/runtime/samples/set-clones-input/_config.js @@ -1,10 +1,14 @@ export default { dev: true, + data: { + a: 42 + }, + test ( assert, component ) { const obj = { a: 1 }; component.set( obj ); component.set( obj ); // will fail if the object is not cloned component.destroy(); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/runtime/samples/store-binding/NameInput.html b/test/runtime/samples/store-binding/NameInput.html new file mode 100644 index 0000000000..a5e4f5e48c --- /dev/null +++ b/test/runtime/samples/store-binding/NameInput.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/runtime/samples/store-binding/_config.js b/test/runtime/samples/store-binding/_config.js new file mode 100644 index 0000000000..aefc4ec652 --- /dev/null +++ b/test/runtime/samples/store-binding/_config.js @@ -0,0 +1,28 @@ +import { Store } from '../../../../store.js'; + +const store = new Store({ + name: 'world' +}); + +export default { + store, + + html: ` +

Hello world!

+ + `, + + test(assert, component, target, window) { + const input = target.querySelector('input'); + const event = new window.Event('input'); + + input.value = 'everybody'; + input.dispatchEvent(event); + + assert.equal(store.get('name'), 'everybody'); + assert.htmlEqual(target.innerHTML, ` +

Hello everybody!

+ + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-binding/main.html b/test/runtime/samples/store-binding/main.html new file mode 100644 index 0000000000..06410ea770 --- /dev/null +++ b/test/runtime/samples/store-binding/main.html @@ -0,0 +1,10 @@ +

Hello {{$name}}!

+ + + \ No newline at end of file diff --git a/test/runtime/samples/store-computed/Todo.html b/test/runtime/samples/store-computed/Todo.html new file mode 100644 index 0000000000..2ad67420ca --- /dev/null +++ b/test/runtime/samples/store-computed/Todo.html @@ -0,0 +1,15 @@ +{{#if isVisible}} +
{{todo.description}}
+{{/if}} + + \ No newline at end of file diff --git a/test/runtime/samples/store-computed/_config.js b/test/runtime/samples/store-computed/_config.js new file mode 100644 index 0000000000..f4e6f49e54 --- /dev/null +++ b/test/runtime/samples/store-computed/_config.js @@ -0,0 +1,63 @@ +import { Store } from '../../../../store.js'; + +class MyStore extends Store { + setFilter(filter) { + this.set({ filter }); + } + + toggleTodo(todo) { + todo.done = !todo.done; + this.set({ todos: this.get('todos') }); + } +} + +const todos = [ + { + description: 'Buy some milk', + done: true, + }, + { + description: 'Do the laundry', + done: true, + }, + { + description: "Find life's true purpose", + done: false, + } +]; + +const store = new MyStore({ + filter: 'all', + todos +}); + +export default { + store, + + html: ` +
Buy some milk
+
Do the laundry
+
Find life's true purpose
+ `, + + test(assert, component, target) { + store.setFilter('pending'); + + assert.htmlEqual(target.innerHTML, ` +
Find life's true purpose
+ `); + + store.toggleTodo(todos[1]); + + assert.htmlEqual(target.innerHTML, ` +
Do the laundry
+
Find life's true purpose
+ `); + + store.setFilter('done'); + + assert.htmlEqual(target.innerHTML, ` +
Buy some milk
+ `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-computed/main.html b/test/runtime/samples/store-computed/main.html new file mode 100644 index 0000000000..5c50839ba3 --- /dev/null +++ b/test/runtime/samples/store-computed/main.html @@ -0,0 +1,11 @@ +{{#each $todos as todo}} + +{{/each}} + + \ No newline at end of file diff --git a/test/runtime/samples/store-event/NameInput.html b/test/runtime/samples/store-event/NameInput.html new file mode 100644 index 0000000000..ecd95d0364 --- /dev/null +++ b/test/runtime/samples/store-event/NameInput.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/runtime/samples/store-event/_config.js b/test/runtime/samples/store-event/_config.js new file mode 100644 index 0000000000..2779db5fc2 --- /dev/null +++ b/test/runtime/samples/store-event/_config.js @@ -0,0 +1,34 @@ +import { Store } from '../../../../store.js'; + +class MyStore extends Store { + setName(name) { + this.set({ name }); + } +} + +const store = new MyStore({ + name: 'world' +}); + +export default { + store, + + html: ` +

Hello world!

+ + `, + + test(assert, component, target, window) { + const input = target.querySelector('input'); + const event = new window.Event('input'); + + input.value = 'everybody'; + input.dispatchEvent(event); + + assert.equal(store.get('name'), 'everybody'); + assert.htmlEqual(target.innerHTML, ` +

Hello everybody!

+ + `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store-event/main.html b/test/runtime/samples/store-event/main.html new file mode 100644 index 0000000000..06410ea770 --- /dev/null +++ b/test/runtime/samples/store-event/main.html @@ -0,0 +1,10 @@ +

Hello {{$name}}!

+ + + \ No newline at end of file diff --git a/test/runtime/samples/store/_config.js b/test/runtime/samples/store/_config.js new file mode 100644 index 0000000000..4e266ff095 --- /dev/null +++ b/test/runtime/samples/store/_config.js @@ -0,0 +1,17 @@ +import { Store } from '../../../../store.js'; + +const store = new Store({ + name: 'world' +}); + +export default { + store, + + html: `

Hello world!

`, + + test(assert, component, target) { + store.set({ name: 'everybody' }); + + assert.htmlEqual(target.innerHTML, `

Hello everybody!

`); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/store/main.html b/test/runtime/samples/store/main.html new file mode 100644 index 0000000000..28154934b8 --- /dev/null +++ b/test/runtime/samples/store/main.html @@ -0,0 +1 @@ +

Hello {{$name}}!

\ No newline at end of file diff --git a/test/server-side-rendering/index.js b/test/server-side-rendering/index.js index a9bf847b29..b6bd109c0e 100644 --- a/test/server-side-rendering/index.js +++ b/test/server-side-rendering/index.js @@ -22,7 +22,8 @@ function tryToReadFile(file) { describe("ssr", () => { before(() => { require("../../ssr/register")({ - extensions: ['.svelte', '.html'] + extensions: ['.svelte', '.html'], + store: true }); return setupHtmlEqual(); @@ -98,9 +99,15 @@ describe("ssr", () => { delete require.cache[resolved]; }); + require("../../ssr/register")({ + store: !!config.store + }); + try { const component = require(`../runtime/samples/${dir}/main.html`); - const html = component.render(config.data); + const html = component.render(config.data, { + store: config.store + }); if (config.html) { assert.htmlEqual(html, config.html); diff --git a/test/store/index.js b/test/store/index.js new file mode 100644 index 0000000000..25099a9066 --- /dev/null +++ b/test/store/index.js @@ -0,0 +1,146 @@ +import assert from 'assert'; +import { Store, combineStores } from '../../store.js'; + +describe('store', () => { + describe('get', () => { + it('gets a specific key', () => { + const store = new Store({ + foo: 'bar' + }); + + assert.equal(store.get('foo'), 'bar'); + }); + + it('gets the entire state object', () => { + const store = new Store({ + foo: 'bar' + }); + + assert.deepEqual(store.get(), { foo: 'bar' }); + }); + }); + + describe('set', () => { + it('sets state', () => { + const store = new Store(); + + store.set({ + foo: 'bar' + }); + + assert.equal(store.get('foo'), 'bar'); + }); + }); + + describe('observe', () => { + it('observes state', () => { + let newFoo; + let oldFoo; + + const store = new Store({ + foo: 'bar' + }); + + store.observe('foo', (n, o) => { + newFoo = n; + oldFoo = o; + }); + + assert.equal(newFoo, 'bar'); + assert.equal(oldFoo, undefined); + + store.set({ + foo: 'baz' + }); + + assert.equal(newFoo, 'baz'); + assert.equal(oldFoo, 'bar'); + }); + }); + + describe('onchange', () => { + it('fires a callback when state changes', () => { + const store = new Store(); + + let count = 0; + let args; + + store.onchange((state, changed) => { + count += 1; + args = { state, changed }; + }); + + store.set({ foo: 'bar' }); + + assert.equal(count, 1); + assert.deepEqual(args, { + state: { foo: 'bar' }, + changed: { foo: true } + }); + + // this should be a noop + store.set({ foo: 'bar' }); + assert.equal(count, 1); + + // this shouldn't + store.set({ foo: 'baz' }); + + assert.equal(count, 2); + assert.deepEqual(args, { + state: { foo: 'baz' }, + changed: { foo: true } + }); + }); + }); + + describe('computed', () => { + it('computes a property based on data', () => { + const store = new Store({ + foo: 1 + }); + + store.compute('bar', ['foo'], foo => foo * 2); + assert.equal(store.get('bar'), 2); + + const values = []; + + store.observe('bar', bar => { + values.push(bar); + }); + + store.set({ foo: 2 }); + assert.deepEqual(values, [2, 4]); + }); + + it('computes a property based on another computed property', () => { + const store = new Store({ + foo: 1 + }); + + store.compute('bar', ['foo'], foo => foo * 2); + store.compute('baz', ['bar'], bar => bar * 2); + assert.equal(store.get('baz'), 4); + + const values = []; + + store.observe('baz', baz => { + values.push(baz); + }); + + store.set({ foo: 2 }); + assert.deepEqual(values, [4, 8]); + }); + + it('prevents computed properties from being set', () => { + const store = new Store({ + foo: 1 + }); + + store.compute('bar', ['foo'], foo => foo * 2); + + assert.throws(() => { + store.set({ bar: 'whatever' }); + }, /'bar' is a read-only property/); + }); + }); +}); diff --git a/test/validator/samples/store-unexpected/input.html b/test/validator/samples/store-unexpected/input.html new file mode 100644 index 0000000000..589f28e647 --- /dev/null +++ b/test/validator/samples/store-unexpected/input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/validator/samples/store-unexpected/warnings.json b/test/validator/samples/store-unexpected/warnings.json new file mode 100644 index 0000000000..bac2841dc9 --- /dev/null +++ b/test/validator/samples/store-unexpected/warnings.json @@ -0,0 +1,8 @@ +[{ + "message": "compile with `store: true` in order to call store methods", + "loc": { + "line": 1, + "column": 18 + }, + "pos": 18 +}] \ No newline at end of file