├── CHANGELOG.md ├── LICENSE ├── README.md ├── autorun.js ├── cursor.js ├── index.js ├── package.js ├── reactive-var.js ├── reactive-var.tests.js ├── subscribe.js ├── use-session.js ├── use-session.tests.js └── use-tracker.js /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 4 | - Make `svelte:compiler` a weak dependency 5 | 6 | ## 0.3.1 7 | - Allow version 3.31.2 of Svelte dependency 8 | 9 | ## 0.3.0 10 | - Update Svelte dependency to at least 3.25.0, revert hack from 0.2.3 11 | 12 | ## 0.2.3 13 | - Implement hack to work around sveltejs/svelte#4899 14 | 15 | ## 0.2.2 16 | - Support `Tracker.autorun()` inside `onMount()` 17 | 18 | ## 0.2.1 19 | - Fix bug with nested `Tracker.autorun()` inside `Tracker.nonreactive()` 20 | 21 | ## 0.2.0 22 | - Support for using `Tracker.autorun()` inside a Svelte `$:` computation 23 | - `useTracker()` is wrapped inside a `Tracker.nonreactive()` 24 | 25 | ## 0.1.0 26 | - Added `useSession()` for tracking Meteor Session variables 27 | 28 | ## 0.0.1 29 | - Initial release 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 rdb 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `svelte-meteor-data` 2 | 3 | This package integrates the [Svelte](https://svelte.dev) UI framework with 4 | Meteor's Tracker system. It makes it easy to write Svelte components which 5 | react automatically to changes in Meteor's data layer. 6 | 7 | This package is still experimental. Use at your peril. 8 | 9 | ## Installation 10 | 11 | To add Svelte to your Meteor app, run: 12 | 13 | ```bash 14 | meteor add svelte:compiler rdb:svelte-meteor-data 15 | meteor npm install --save svelte@3.31.2 16 | ``` 17 | 18 | ## Usage 19 | 20 | Unlike in Blaze, Svelte does not automatically become aware of changes to Meteor 21 | state, even inside `$:` blocks. This package provides some features that enable 22 | Svelte to become aware of such changes. 23 | 24 | ### Reactive computations with `useTracker` 25 | 26 | The `useTracker()` function can be used to expose any reactive computation as a 27 | Svelte store. You need only pass a callable returning a computed value, which 28 | will be run the first time it is used and then every time the computed value 29 | changes. The updated value is automatically made available to Svelte. 30 | 31 | For example, this example makes the current Meteor user available in a 32 | component, and causes Svelte to update the appropriate element automatically 33 | when the current user changes: 34 | 35 | ```svelte 36 | 41 | 42 |

Welcome {$currentUser.username}!

43 | ``` 44 | 45 | You can even mix Meteor reactivity with Svelte reactivity: 46 | 47 | ```svelte 48 | 55 | 56 |

Selected {$selectedUser.username}

57 | ``` 58 | 59 | ### Cursors 60 | 61 | While it's possible to use queries with `useTracker(() => query.fetch())`, this 62 | package supports a more convenient way to handle reactive queries, by allowing 63 | you to use a MongoDB cursor directly as a Svelte store: 64 | 65 | ```svelte 66 | 71 | 72 |

Showing {$fruits.length} {fruitColor}-colored fruits:

73 | 78 | ``` 79 | 80 | ### Subscriptions 81 | 82 | You can safely use `Meteor.subscribe` in your components without worrying about 83 | clean-up. The subscription will be stopped automatically when the component is 84 | destroyed. 85 | 86 | As an added feature, you can use a subscription handle in an `{#await}` block: 87 | 88 | ```svelte 89 | {#await Meteor.subscribe('todos')} 90 |

Loading todos…

91 | {:then} 92 | 93 | {/await} 94 | ``` 95 | 96 | ### Tracker.autorun 97 | 98 | It is possible to use `Tracker.autorun()` with a function that is automatically 99 | re-run when its Meteor dependencies change. It will stop being updated when the 100 | component is destroyed. This will work fine for top-level computations that do 101 | not depend on any dynamic Svelte state, such as in this example: 102 | 103 | ```svelte 104 | 111 | ``` 112 | 113 | To make the autorun also respond to Svelte state changes, you need to put it 114 | under a `$:` block. This will work, but with some caveats: if the Tracker state 115 | is invalidated right after a change to the Svelte state, all `$:` blocks will be 116 | re-run. It is therefore better to use `useTracker` instead, as listed above. 117 | 118 | ### ReactiveVar 119 | 120 | A Meteor ReactiveVar will work seamlessly as a Svelte store, and can be accessed 121 | and bound like any writable store using the `$` operator: 122 | 123 | ```svelte 124 | 129 | 130 | 131 | 132 |

Value is {$store}

133 | ``` 134 | 135 | ### Session variables 136 | 137 | If you are using Meteor [Session](https://docs.meteor.com/api/session.html) 138 | variables, these can be exposed as a reactive Svelte store using the 139 | `useSession` hook. The first argument is the session key to expose, and the 140 | optional second argument allows you to set a default value for the session 141 | variable, as an added convenience. 142 | 143 | This function is only available if the `session` package has been added. 144 | 145 | ```svelte 146 | 155 | 156 | 157 | 158 |

Value is {$store}

159 | ``` 160 | -------------------------------------------------------------------------------- /autorun.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes Tracker.autorun() computations automatically stop when the component is 3 | * destroyed, or, if run from a reactive Svelte computation, when the update 4 | * function is run again. 5 | */ 6 | 7 | import { Tracker } from 'meteor/tracker'; 8 | import { current_component, schedule_update, dirty_components } from 'svelte/internal'; 9 | 10 | 11 | const _autorun = Tracker.autorun; 12 | const _nonreactive = Tracker.nonreactive; 13 | 14 | function svelteAwareAutorun(f, options) { 15 | const component = current_component; 16 | const computation = _autorun.apply(this, arguments); 17 | if (component) { 18 | // We're inside a Svelte component. We have to stop the computation when 19 | // the component is destroyed. 20 | _autoStopComputation(computation, component); 21 | } 22 | return computation; 23 | } 24 | 25 | Tracker.autorun = svelteAwareAutorun; 26 | 27 | Tracker.nonreactive = function nonreactive(f) { 28 | if (current_component) { 29 | // A Tracker.autorun inside a Tracker.nonreactive should behave normally, 30 | // without the special Svelte stuff. 31 | const prevAutorun = Tracker.autorun; 32 | Tracker.autorun = _autorun; 33 | try { 34 | return _nonreactive.apply(this, arguments); 35 | } finally { 36 | Tracker.autorun = prevAutorun; 37 | } 38 | } else { 39 | return _nonreactive.apply(this, arguments); 40 | } 41 | }; 42 | 43 | function _autoStopComputation(computation, component) { 44 | const $$ = component.$$; 45 | $$.on_destroy.push(computation.stop.bind(computation)); 46 | if (!$$.ctx) { 47 | // We're in initialization, so nothing else to do. 48 | return; 49 | } 50 | 51 | if ($$.fragment && $$.dirty[0] === -1) { 52 | // We have a fragment, but it's set to the initial dirty state, so we must 53 | // be in on onMount or so. Don't do anything special, then. 54 | return; 55 | } 56 | 57 | // We are in a reactive Svelte update. That means that we'll need to stop the 58 | // computation the next time that it is run. But we don't know when that is, 59 | // because the next update may or may not hit this autorun again, depending on 60 | // the dirty flags. 61 | // So, we simply stop all computations the next time that the update is run, 62 | // but we keep listening for invalidations, so that if one of them becomes 63 | // invalid, we can force Svelte to re-run the updates to make it hit the 64 | // autorun again. 65 | 66 | // But first, remember which dirty flags made this autorun trigger, so that we 67 | // can reuse these bits to force Svelte to re-hit the autorun. 68 | // This will unfortunately most of the time be all bits set, since the first 69 | // time it is called is usually during initialization. But if the autorun is 70 | // first enabled by a Svelte variable change, it will be a bit more efficient. 71 | computation._savedDirty = [...$$.dirty]; 72 | 73 | if ($$._stopComputations) { 74 | $$._stopComputations.push(computation); 75 | return; 76 | } 77 | 78 | $$._stopComputations = [computation]; 79 | 80 | // Temporary hook around the update function so that it stops our computation 81 | // the next time it is called. 82 | const _update = $$.update; 83 | $$.update = () => { 84 | // Optimization: are we about to rerun everything? If so, don't bother with 85 | // onInvalidate, just stop the computations right here. 86 | if ($$.dirty.every(d => (d === 0x7fffffff))) { 87 | $$._stopComputations.forEach(comp => comp.stop()); 88 | } else { 89 | // Otherwise, we are not sure whether all the autorun blocks will run 90 | // again, so we prevent the computations from continuing to run, but will 91 | // continue to watch it for changes. If there is a change, we require the 92 | // update to be run again. 93 | for (const comp of $$._stopComputations) { 94 | comp.stopped = true; 95 | comp.onInvalidate(() => { 96 | if ($$.dirty[0] === -1) { 97 | // We're the first to mark it dirty since the last update. 98 | dirty_components.push(component); 99 | schedule_update(); 100 | $$.dirty.fill(0); 101 | } 102 | comp._savedDirty.forEach((mask, i) => { 103 | $$.dirty[i] |= mask & 0x7fffffff; 104 | }); 105 | }); 106 | } 107 | } 108 | 109 | // Leave everything as it was, so that the overhead is removed if the 110 | // Tracker.autorun was under a condition that has now becomes false. 111 | delete $$._stopComputations; 112 | $$.update = _update; 113 | return _update(); 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /cursor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements the Svelte store contract for MongoDB cursors. 3 | */ 4 | 5 | import { Mongo } from 'meteor/mongo'; 6 | 7 | Mongo.Cursor.prototype.subscribe = function(set) { 8 | // Set the initial result directly, without going through the callbacks 9 | const mapFn = this._transform 10 | ? element => this._transform(this._projectionFn(element)) 11 | : element => this._projectionFn(element); 12 | 13 | let result = this._getRawObjects({ordered: true}).map(mapFn); 14 | 15 | const handle = this.observe({ 16 | _suppress_initial: true, 17 | addedAt: (doc, i) => { 18 | result = [...result.slice(0, i), doc, ...result.slice(i)]; 19 | set(result); 20 | }, 21 | changedAt: (doc, old, i) => { 22 | result = [...result.slice(0, i), doc, ...result.slice(i + 1)]; 23 | set(result); 24 | }, 25 | removedAt: (old, i) => { 26 | result = [...result.slice(0, i), ...result.slice(i + 1)]; 27 | set(result); 28 | }, 29 | movedTo: (doc, from, to) => { 30 | result = [...result.slice(0, from), ...result.slice(from + 1)]; 31 | result.splice(to, 0, doc); 32 | set(result); 33 | }, 34 | }); 35 | 36 | set(result); 37 | return handle.stop.bind(this); 38 | }; 39 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; 2 | 3 | if(Meteor.isServer){ 4 | checkNpmVersions({ 5 | 'svelte': ">=3.25.0" 6 | }, 'rdb:svelte-meteor-data'); 7 | // this require is here only to make sure we can validate the npm dependency above 8 | require(`svelte/package.json`) 9 | } 10 | export { default as useTracker } from './use-tracker'; 11 | 12 | import './subscribe'; 13 | 14 | if (Package['mongo']) { 15 | import './cursor'; 16 | } 17 | 18 | if (Package['reactive-var']) { 19 | import './reactive-var'; 20 | } 21 | 22 | if (Package['session'] && Meteor.isClient) { 23 | export { default as useSession } from './use-session'; 24 | } 25 | 26 | // Import this last, since it overwrites the built-in Tracker.autorun 27 | import './autorun'; 28 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'rdb:svelte-meteor-data', 3 | version: '1.0.0', 4 | summary: 'Reactively track Meteor data inside Svelte components', 5 | git: 'https://github.com/rdb/svelte-meteor-data', 6 | documentation: 'README.md' 7 | }); 8 | 9 | Package.onUse(function(api) { 10 | api.versionsFrom('1.8'); 11 | api.use('ecmascript'); 12 | api.use('tracker'); 13 | api.use('tmeasday:check-npm-versions@1.0.2'); 14 | api.use('svelte:compiler@3.31.2 || 3.25.0', {weak: true}); 15 | api.use('reactive-var', {weak: true}); 16 | api.use('session', 'client', {weak: true}); 17 | api.use('mongo', {weak: true}); 18 | api.mainModule('index.js'); 19 | }); 20 | 21 | Package.onTest(function(api) { 22 | api.use('ecmascript'); 23 | api.use('tinytest'); 24 | api.use('rdb:svelte-meteor-data'); 25 | api.use('tmeasday:check-npm-versions@1.0.2'); 26 | api.use('reactive-var'); 27 | api.use('session', 'client'); 28 | api.addFiles('reactive-var.tests.js'); 29 | api.addFiles('use-session.tests.js', 'client'); 30 | }); 31 | -------------------------------------------------------------------------------- /reactive-var.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes ReactiveVar behave as a Svelte store. 3 | */ 4 | 5 | import { ReactiveVar } from 'meteor/reactive-var'; 6 | 7 | let nextId = 1; 8 | 9 | ReactiveVar.prototype.subscribe = function subscribe(set) { 10 | const value = this.curValue; 11 | if (value !== undefined) { 12 | set(value); 13 | } 14 | const id = `svelte-${nextId++}`; 15 | this.dep._dependentsById[id] = { 16 | _id: id, 17 | invalidate: () => { 18 | set(this.curValue); 19 | }, 20 | }; 21 | return () => { 22 | delete this.dep._dependentsById[id]; 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /reactive-var.tests.js: -------------------------------------------------------------------------------- 1 | import { Tinytest } from "meteor/tinytest"; 2 | import { ReactiveVar } from 'meteor/reactive-var'; 3 | 4 | import './reactive-var'; 5 | 6 | 7 | Tinytest.add('ReactiveVar store contract', function (test) { 8 | const rvar = new ReactiveVar("initial"); 9 | 10 | let setterCalled = 0; 11 | let setterCalledWith; 12 | 13 | function setter(value) { 14 | setterCalled += 1; 15 | setterCalledWith = value; 16 | } 17 | 18 | const unsub = rvar.subscribe(setter); 19 | test.equal(setterCalled, 1, 'Subscribe should have called setter once'); 20 | test.equal(setterCalledWith, "initial", 'Subscribe should have set initial value'); 21 | 22 | rvar.set("initial"); 23 | test.equal(setterCalled, 1, 'Setter should not be called if value is not changed'); 24 | 25 | rvar.get(); 26 | test.equal(setterCalled, 1, 'Setter should not be called on ReactiveVar.get()'); 27 | 28 | rvar.set("new"); 29 | test.equal(setterCalled, 2, 'Setter should be called if value is changed'); 30 | test.equal(setterCalledWith, "new", 'Setter should be called with new value'); 31 | 32 | unsub(); 33 | 34 | test.equal(setterCalled, 2, 'Unsubscribe should not call setter'); 35 | 36 | rvar.set("newer"); 37 | test.equal(setterCalled, 2, 'Setter may not be called after unsubscribe'); 38 | }); 39 | -------------------------------------------------------------------------------- /subscribe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Overrides Meteor.subscribe to do the following things: 3 | * - Automatically stops the subscription when the component is destroyed 4 | * - Makes the return value usable in {#await} blocks 5 | */ 6 | 7 | import { current_component } from 'svelte/internal'; 8 | 9 | 10 | _subscribe = Meteor.subscribe; 11 | Meteor.subscribe = function subscribe(name) { 12 | const params = Array.from(arguments); 13 | let callbacks = Object.create(null); 14 | if (params.length > 1) { 15 | // Preserve existing callbacks. 16 | const last = params[params.length - 1]; 17 | if (last) { 18 | // Last arg may be specified as a function, or as an object 19 | if (typeof last === 'function') { 20 | callbacks.onReady = params.pop(); 21 | } else if ([last.onReady, last.onError, last.onStop].some(f => typeof f === "function")) { 22 | callbacks = params.pop(); 23 | } 24 | } 25 | } 26 | params.push(callbacks); 27 | 28 | let subscription; 29 | 30 | // Collect callbacks to call when subscription is ready or has errored. 31 | let readyCallbacks = []; 32 | let errorCallbacks = []; 33 | if (callbacks.onReady) { 34 | readyCallbacks.push(callbacks.onReady); 35 | } 36 | if (callbacks.onError) { 37 | errorCallbacks.push(callbacks.onError); 38 | } 39 | callbacks.onReady = () => { 40 | readyCallbacks.forEach(fn => fn(subscription)); 41 | readyCallbacks.length = 0; 42 | }; 43 | callbacks.onError = (err) => { 44 | errorCallbacks.forEach(fn => fn(err)); 45 | errorCallbacks.length = 0; 46 | }; 47 | 48 | subscription = _subscribe.apply(this, params); 49 | if (current_component) { 50 | current_component.$$.on_destroy.push(subscription.stop.bind(subscription)); 51 | } 52 | subscription.then = (fn, err) => { 53 | if (subscription.ready()) { 54 | fn(); 55 | } else { 56 | readyCallbacks.push(fn); 57 | err && errorCallbacks.push(err); 58 | } 59 | }; 60 | return subscription; 61 | }; 62 | -------------------------------------------------------------------------------- /use-session.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function wraps a Meteor Session variable as a Svelte store. 3 | */ 4 | 5 | import { Session } from "meteor/session"; 6 | import { EJSON } from "meteor/ejson"; 7 | 8 | let nextId = 1; 9 | 10 | const parse = serialized => 11 | (serialized !== undefined && serialized !== 'undefined') 12 | ? EJSON.parse(serialized) 13 | : undefined; 14 | 15 | export default function useSession(key, defaultValue) { 16 | if (arguments.length > 1) { 17 | Session.setDefault(key, defaultValue); 18 | } 19 | 20 | return { 21 | subscribe(set) { 22 | Session._ensureKey(key); 23 | const dep = Session.keyDeps[key]; 24 | if (Object.prototype.hasOwnProperty.call(Session.keys, key)) { 25 | set(parse(Session.keys[key])); 26 | } 27 | 28 | const id = `svelte-session-${nextId++}`; 29 | dep._dependentsById[id] = { 30 | _id: id, 31 | invalidate: () => { 32 | set(parse(Session.keys[key])); 33 | }, 34 | }; 35 | 36 | return () => { 37 | delete dep._dependentsById[id]; 38 | }; 39 | }, 40 | set(value) { 41 | Session.set(key, value); 42 | }, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /use-session.tests.js: -------------------------------------------------------------------------------- 1 | import { Tinytest } from "meteor/tinytest"; 2 | import { Session } from "meteor/session"; 3 | 4 | import { default as useSession } from './use-session'; 5 | 6 | 7 | Tinytest.add('useSession default value', function (test) { 8 | Session.delete('test1'); 9 | 10 | useSession('test1'); 11 | 12 | test.isFalse(Object.prototype.hasOwnProperty.call(Session.keys, 'test1'), 13 | 'Should not set default value without second arg'); 14 | 15 | useSession('test2', 'value1') 16 | test.equal(Session.keys['test2'], '"value1"', 17 | 'Should set default value with second arg'); 18 | 19 | useSession('test2', 'value2'); 20 | test.equal(Session.keys['test2'], '"value1"', 21 | 'Second arg should not overwrite existing set value'); 22 | 23 | useSession('test2'); 24 | test.equal(Session.keys['test2'], '"value1"', 25 | 'Undefined second arg should overwrite existing value'); 26 | }); 27 | 28 | 29 | Tinytest.add('useSession reactivity', function (test) { 30 | Session.delete('test3'); 31 | 32 | const store = useSession('test3', 'initial'); 33 | 34 | let setterCalled = 0; 35 | let setterCalledWith; 36 | 37 | function setter(value) { 38 | setterCalled += 1; 39 | setterCalledWith = value; 40 | } 41 | 42 | const unsub = store.subscribe(setter); 43 | test.equal(setterCalled, 1, 'Subscribe should have called setter once'); 44 | test.equal(setterCalledWith, "initial", 'Subscribe should have set initial value'); 45 | 46 | store.set("initial"); 47 | test.equal(setterCalled, 1, 'Setter should not be called if value is not changed'); 48 | 49 | Session.set("test3", "initial"); 50 | test.equal(setterCalled, 1, 'Setter should not be called if value is not changed via Session'); 51 | 52 | Session.get("test3"); 53 | test.equal(setterCalled, 1, 'Setter should not be called on Session.get()'); 54 | 55 | store.set("new"); 56 | test.equal(setterCalled, 2, 'Setter should be called if value is changed via set()'); 57 | test.equal(setterCalledWith, "new", 'Setter should be called with new value on set()'); 58 | 59 | Session.set("test3", "newer"); 60 | test.equal(setterCalled, 3, 'Setter should be called if value is changed via Session.set()'); 61 | test.equal(setterCalledWith, "newer", 'Setter should be called with new value on Session.set()'); 62 | 63 | unsub(); 64 | 65 | test.equal(setterCalled, 3, 'Unsubscribe should not call setter'); 66 | 67 | store.set("newest"); 68 | test.equal(setterCalled, 3, 'Setter may not be called after unsubscribe'); 69 | 70 | Session.set("test3", "newest"); 71 | test.equal(setterCalled, 3, 'Setter may not be called after unsubscribe'); 72 | }); 73 | -------------------------------------------------------------------------------- /use-tracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function wraps a reactive Meteor computation as a Svelte store. 3 | */ 4 | 5 | const nonreactive = Tracker.nonreactive; 6 | const autorun = Tracker.autorun; 7 | 8 | export default function useTracker(reactiveFn) { 9 | return { 10 | subscribe(set) { 11 | return nonreactive(() => { 12 | const computation = autorun(() => set(reactiveFn())); 13 | return computation.stop.bind(computation); 14 | }); 15 | }, 16 | }; 17 | }; 18 | --------------------------------------------------------------------------------