Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deps extensions #193

Closed
wants to merge 11 commits into from
118 changes: 118 additions & 0 deletions packages/deps-extensions/deps-extensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
(function(Meteor) {
// Add 3 functions to an object to create a reactive variable on it.
//
// For example Router.add_reactive_variable('current_page', initial_value) will create three methods:
//
// - Router.current_page(not_reactive = false):
// reads the value of current_page, reactively?
//
// - Router.current_page.equals(value):
// is current_page === value ala the session
//
// - Router.current_page.set(value):
// changes the value of current_page, reactively
// (i.e. invalidates all contexts that have read this variable)

Meteor.deps.add_reactive_variable = function(object, name, value) {
// the variable is hidden via closures
var variable = value;
var contexts = {}, equals_contexts = {};


object[name] = function(not_reactive) {
return Meteor.deps.add_reactive_variable.read_variable(not_reactive, variable, contexts);
};

object[name].equals = function(value) {
return Meteor.deps.add_reactive_variable.variable_equals(value, variable, equals_contexts);
}

object[name].set = function(new_value) {
variable = Meteor.deps.add_reactive_variable.set_variable(new_value, variable, contexts, equals_contexts);
}
};

_.extend(Meteor.deps.add_reactive_variable, {
read_variable: function (not_reactive, variable, contexts) {
// templates will pass in an object here, so we want to be sure they've passed true
if (not_reactive === true)
return variable;

var context = Meteor.deps.Context.current;

if (context && !(context.id in contexts)) {
contexts[context.id] = context;
context.on_invalidate(function () {
delete contexts[contexts.id];
});
}

return variable;
},

variable_equals: function(value, variable, equals_contexts) {
var context = Meteor.deps.Context.current;
if (context) {
if (!(value in equals_contexts))
equals_contexts[value] = {};

if (!(context.id in equals_contexts[value])) {
equals_contexts[value][context.id] = context;
context.on_invalidate(function () {
delete equals_contexts[value][context.id];

// clean up [key][value] if it's now empty, so we don't use
// O(n) memory for n = values seen ever
for (var x in equals_contexts[value])
return;
delete equals_contexts[value];
});
}
}
return variable === value;
},

set_variable: function(new_value, variable, contexts, equals_contexts) {
var old_value = variable;
if (new_value === old_value)
return old_value;

var invalidate = function (map) {
if (map)
for (var id in map)
map[id].invalidate();
};

invalidate(contexts);
invalidate(equals_contexts[old_value]);
invalidate(equals_contexts[new_value]);

return new_value;
}
});

// listen to a reactive fn and when it returns true call callback.
//
// Example (continuing from above):
// Meteor.deps.await(function() { Router.current_page_equals('home'); }, function() { console.log('at home'); });
Meteor.deps.await = function(test_fn, callback, once) {
var done = false;
var context = new Meteor.deps.Context();
context.on_invalidate(function() {
if (!(done && once))
Meteor.deps.await(test_fn, callback, once);
});

context.run(function() {
if (test_fn()) {
done = true;
callback();
}
});
};

// convience function for await(fn, cb, true)
Meteor.deps.await_once = function(fn, cb) { Meteor.deps.await(fn, cb, true) }

}(Meteor));

101 changes: 101 additions & 0 deletions packages/deps-extensions/deps-extensions_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
Tinytest.add("add reactive variable basics", function(test) {
var obj = {};
Meteor.deps.add_reactive_variable(obj, 'foo', 'default');

test.equal(obj.foo(), 'default');
test.equal(obj.foo.equals('default'), true);
test.equal(obj.foo.equals('random'), false);

obj.foo.set('random');
test.equal(obj.foo(), 'random');
test.equal(obj.foo.equals('default'), false);
test.equal(obj.foo.equals('random'), true);

// woops, better make sure resetting to the same value doesn't break us
obj.foo.set('random');
test.equal(obj.foo(), 'random');
});


var test_code_invalidates = function(test, obj, should_be, callback) {
obj.foo.set('first');

var context = new Meteor.deps.Context();
var invalidated = false;
context.on_invalidate(function() { invalidated = true; });
context.run(callback);
test.equal(invalidated, false);

obj.foo.set('second');
Meteor.flush();
test.equal(invalidated, should_be);
}

Tinytest.add("add reactive variable reactivity", function(test) {
var obj = {};
Meteor.deps.add_reactive_variable(obj, 'foo', 'default');
Meteor.deps.add_reactive_variable(obj, 'bar', 'default');

// this always invalidates when we change foo
test_code_invalidates(test, obj, true, function() {
test.equal(obj.foo(), 'first');
});

// this never invalidates when we change foo
test_code_invalidates(test, obj, false, function() {
test.equal(obj.bar(), 'default');
});

// reading foo(true) shouldn't behave reactively
test_code_invalidates(test, obj, false, function() {
test.equal(obj.foo(true), 'first');
});

// this should invalidate as we go first -> second
test_code_invalidates(test, obj, true, function() {
test.equal(obj.foo.equals('first'), true);
});

// this should invalidate as we go first -> second
test_code_invalidates(test, obj, true, function() {
test.equal(obj.foo.equals('second'), false);
});

// this should NOT invalidate because we go first -> second (third isn't involved)
test_code_invalidates(test, obj, false, function() {
test.equal(obj.foo.equals('third'), false);
});
});

Tinytest.add("await", function(test) {
var obj = {};
Meteor.deps.add_reactive_variable(obj, 'foo', 'default');

var await_called = 0;
Meteor.deps.await(function() { return obj.foo.equals(5); }, function() {
await_called += 1;
})

var await_once_called = 0;
Meteor.deps.await_once(function() { return obj.foo.equals(5); }, function() {
await_once_called += 1;
})

test.equal(await_called, 0);
test.equal(await_once_called, 0);

obj.foo.set(5);
Meteor.flush();
test.equal(await_called, 1);
test.equal(await_once_called, 1);

obj.foo.set(6);
Meteor.flush();
test.equal(await_called, 1);
test.equal(await_once_called, 1);

obj.foo.set(5);
Meteor.flush();
test.equal(await_called, 2);
test.equal(await_once_called, 1);
});
19 changes: 19 additions & 0 deletions packages/deps-extensions/package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Package.describe({
summary: "Extension to the deps package to simplify common tasks"
});

Package.on_use(function (api, where) {
where = where || ['client', 'server'];

api.use('deps', where);
api.add_files('deps-extensions.js', where);
});


Package.on_test(function (api) {
api.use('deps-extensions', ['client', 'server']);
api.use('test-helpers', ['client', 'server']);
api.use('tinytest');

api.add_files('deps-extensions_tests.js', ['client', 'server']);
});
11 changes: 10 additions & 1 deletion packages/session/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ var _ = require('../../packages/underscore/underscore.js');
Package.on_use(function (api, where) {
where = where || ['client', 'server'];

api.use(['underscore', 'deps'], where);
api.use(['underscore', 'deps', 'deps-extensions'], where);
// XXX what I really want to do is ensure that if 'reload' is going to
// be loaded, it should be loaded before 'session'. Session can work
// with or without reload.
Expand All @@ -20,3 +20,12 @@ Package.on_use(function (api, where) {

api.add_files('session.js', where);
});

Package.on_test(function (api) {
api.use('session', ['client', 'server']);
api.use('test-helpers', ['client', 'server']);
api.use('tinytest');

api.add_files('session_tests.js', ['client', 'server']);
api.add_files('session_client_tests.js', ['client']);
});
84 changes: 19 additions & 65 deletions packages/session/session.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// XXX could use some tests

Session = _.extend({}, {
keys: {},
key_deps: {}, // key -> context id -> context
key_value_deps: {}, // key -> value -> context id -> context

keys: [],
data: {},

// XXX remove debugging method (or improve it, but anyway, don't
// ship it in production)
dump_state: function () {
Expand All @@ -29,76 +26,27 @@ Session = _.extend({}, {

set: function (key, value) {
var self = this;

var old_value = self.keys[key];
if (value === old_value)
return;
self.keys[key] = value;

var invalidate = function (map) {
if (map)
for (var id in map)
map[id].invalidate();
};

self._ensureKey(key);
invalidate(self.key_deps[key]);
invalidate(self.key_value_deps[key][old_value]);
invalidate(self.key_value_deps[key][value]);
self.data[key].set(value);
},

get: function (key) {
var self = this;
var context = Meteor.deps.Context.current;
self._ensureKey(key);

if (context && !(context.id in self.key_deps[key])) {
self.key_deps[key][context.id] = context;
context.on_invalidate(function () {
delete self.key_deps[key][context.id];
});
}

return self.keys[key];
return self.data[key]();
},

equals: function (key, value) {
var self = this;
var context = Meteor.deps.Context.current;

if (typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
value !== null && value !== undefined)
throw new Error("Session.equals: value can't be an object");

if (context) {
self._ensureKey(key);
if (!(value in self.key_value_deps[key]))
self.key_value_deps[key][value] = {};

if (!(context.id in self.key_value_deps[key][value])) {
self.key_value_deps[key][value][context.id] = context;
context.on_invalidate(function () {
delete self.key_value_deps[key][value][context.id];

// clean up [key][value] if it's now empty, so we don't use
// O(n) memory for n = values seen ever
for (var x in self.key_value_deps[key][value])
return;
delete self.key_value_deps[key][value];
});
}
}

return self.keys[key] === value;
self._ensureKey(key);
return self.data[key].equals(value);
},

_ensureKey: function (key) {
var self = this;
if (!(key in self.key_deps)) {
self.key_deps[key] = {};
self.key_value_deps[key] = {};
if (_.indexOf(self.keys, key) == -1) {
self.keys.push(key);
Meteor.deps.add_reactive_variable(self.data, key);
}
}
});
Expand All @@ -107,13 +55,19 @@ Session = _.extend({}, {
if (Meteor._reload) {
Meteor._reload.on_migrate('session', function () {
// XXX sanitize and make sure it's JSONible?
return [true, {keys: Session.keys}];
var data = {};
_.each(Session.keys, function(key) {
data[key] = Session.data[key](true);
});
return [true, {data: data}];
});

(function () {
var migration_data = Meteor._reload.migration_data('session');
if (migration_data && migration_data.keys) {
Session.keys = migration_data.keys;
if (migration_data && migration_data.data) {
for (var key in migration_data.data) {
Session.set(key, migration_data.data[key])
}
}
})();
}
Loading