diff --git a/docs/client/api.html b/docs/client/api.html
index 22dae6cc5d..3aaefe2aec 100644
--- a/docs/client/api.html
+++ b/docs/client/api.html
@@ -858,7 +858,7 @@ Example:
{{> api_box equals}}
-These two expressions do the same thing:
+If value is a scalar, then these two expressions do the same thing:
(1) Session.get("key") === value
(2) Session.equals("key", value)
@@ -905,6 +905,10 @@ Example:
// If Session.get had been used instead of Session.equals, then
// when the selection changed, all the items would be re-rendered.
+For object and array session values, you cannot use `Session.equals`; instead,
+you need to use the `underscore` package and write
+`_.isEqual(Session.get(key), value)`.
+
Templates
A template that you declare as `<{{! }}template name="foo"> ... {{!
diff --git a/docs/client/api.js b/docs/client/api.js
index 44f167cf67..bc904124c7 100644
--- a/docs/client/api.js
+++ b/docs/client/api.js
@@ -726,7 +726,7 @@ Template.api.set = {
type: "String",
descr: "The key to set, eg, `selectedItem`"},
{name: "value",
- type: "String, Number, Boolean, null, or undefined",
+ type: "JSON-able object or undefined",
descr: "The new value for `key`"}
]
};
@@ -735,7 +735,7 @@ Template.api.get = {
id: "session_get",
name: "Session.get(key)",
locus: "Client",
- descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set)."],
+ descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set). This returns a clone of the session value, so if it's an object or an array, mutating the returned value has no effect on the value stored in the session."],
args: [
{name: "key",
type: "String",
diff --git a/packages/session/package.js b/packages/session/package.js
index f0312ee2e6..5d5a937a13 100644
--- a/packages/session/package.js
+++ b/packages/session/package.js
@@ -20,3 +20,9 @@ Package.on_use(function (api, where) {
api.add_files('session.js', where);
});
+
+Package.on_test(function (api) {
+ api.use('tinytest');
+ api.use('session', 'client');
+ api.add_files('session_tests.js', 'client');
+});
diff --git a/packages/session/session.js b/packages/session/session.js
index c55d5b39e5..e61c06170f 100644
--- a/packages/session/session.js
+++ b/packages/session/session.js
@@ -1,93 +1,117 @@
-// XXX could use some tests
+(function () {
-Session = _.extend({}, {
- keys: {}, // key -> value
- keyDeps: {}, // key -> _ContextSet
- keyValueDeps: {}, // key -> value -> _ContextSet
+ // XXX come up with a serialization method which canonicalizes object key
+ // order, which would allow us to use objects as values for equals.
+ var stringify = function (value) {
+ if (value === undefined)
+ return 'undefined';
+ return JSON.stringify(value);
+ };
+ var parse = function (serialized) {
+ if (serialized === undefined || serialized === 'undefined')
+ return undefined;
+ return JSON.parse(serialized);
+ };
- set: function (key, value) {
- var self = this;
+ Session = _.extend({}, {
+ keys: {}, // key -> value
+ keyDeps: {}, // key -> _ContextSet
+ keyValueDeps: {}, // key -> value -> _ContextSet
- if (typeof value !== 'string' &&
- typeof value !== 'number' &&
- typeof value !== 'boolean' &&
- value !== null && value !== undefined)
- throw new Error("Session.set: value can't be an object");
+ set: function (key, value) {
+ var self = this;
- var oldValue = self.keys[key];
- if (value === oldValue)
- return;
- self.keys[key] = value;
+ value = stringify(value);
- var invalidateAll = function (cset) {
- cset && cset.invalidateAll();
- };
+ var oldSerializedValue = 'undefined';
+ if (_.has(self.keys, key)) oldSerializedValue = self.keys[key];
+ if (value === oldSerializedValue)
+ return;
+ self.keys[key] = value;
- invalidateAll(self.keyDeps[key]);
- if (self.keyValueDeps[key]) {
- invalidateAll(self.keyValueDeps[key][oldValue]);
- invalidateAll(self.keyValueDeps[key][value]);
- }
- },
+ var invalidateAll = function (cset) {
+ cset && cset.invalidateAll();
+ };
- get: function (key) {
- var self = this;
- self._ensureKey(key);
- self.keyDeps[key].addCurrentContext();
- return self.keys[key];
- },
+ invalidateAll(self.keyDeps[key]);
+ if (self.keyValueDeps[key]) {
+ invalidateAll(self.keyValueDeps[key][oldSerializedValue]);
+ invalidateAll(self.keyValueDeps[key][value]);
+ }
+ },
- equals: function (key, value) {
- var self = this;
- var context = Meteor.deps.Context.current;
-
- if (typeof value !== 'string' &&
- typeof value !== 'number' &&
- typeof value !== 'boolean' &&
- typeof value !== 'undefined' &&
- value !== null)
- throw new Error("Session.equals: value can't be an object");
-
- if (context) {
+ get: function (key) {
+ var self = this;
self._ensureKey(key);
+ self.keyDeps[key].addCurrentContext();
+ return parse(self.keys[key]);
+ },
- if (!(value in self.keyValueDeps[key]))
- self.keyValueDeps[key][value] = new Meteor.deps._ContextSet;
+ equals: function (key, value) {
+ var self = this;
+ var context = Meteor.deps.Context.current;
- var isNew = self.keyValueDeps[key][value].add(context);
- if (isNew) {
- context.onInvalidate(function () {
- // clean up [key][value] if it's now empty, so we don't use
- // O(n) memory for n = values seen ever
- if (self.keyValueDeps[key][value].isEmpty())
- delete self.keyValueDeps[key][value];
- });
+ // We don't allow objects (or arrays that might include objects) for
+ // .equals, because JSON.stringify doesn't canonicalize object key
+ // order. (We can make equals have the right return value by parsing the
+ // current value and using _.isEqual, but we won't have a canonical
+ // element of keyValueDeps[key] to store the context.) You can still use
+ // "_.isEqual(Session.get(key), value)".
+ //
+ // XXX we could allow arrays as long as we recursively check that there
+ // are no objects
+ if (typeof value !== 'string' &&
+ typeof value !== 'number' &&
+ typeof value !== 'boolean' &&
+ typeof value !== 'undefined' &&
+ value !== null)
+ throw new Error("Session.equals: value must be scalar");
+ var serializedValue = stringify(value);
+
+ if (context) {
+ self._ensureKey(key);
+
+ if (! _.has(self.keyValueDeps[key], serializedValue))
+ self.keyValueDeps[key][serializedValue] = new Meteor.deps._ContextSet;
+
+ var isNew = self.keyValueDeps[key][serializedValue].add(context);
+ if (isNew) {
+ context.onInvalidate(function () {
+ // clean up [key][serializedValue] if it's now empty, so we don't
+ // use O(n) memory for n = values seen ever
+ if (self.keyValueDeps[key][serializedValue].isEmpty())
+ delete self.keyValueDeps[key][serializedValue];
+ });
+ }
+ }
+
+ var oldValue = undefined;
+ if (_.has(self.keys, key)) oldValue = parse(self.keys[key]);
+ return oldValue === value;
+ },
+
+ _ensureKey: function (key) {
+ var self = this;
+ if (!(key in self.keyDeps)) {
+ self.keyDeps[key] = new Meteor.deps._ContextSet;
+ self.keyValueDeps[key] = {};
}
}
-
- return self.keys[key] === value;
- },
-
- _ensureKey: function (key) {
- var self = this;
- if (!(key in self.keyDeps)) {
- self.keyDeps[key] = new Meteor.deps._ContextSet;
- self.keyValueDeps[key] = {};
- }
- }
-});
-
-
-if (Meteor._reload) {
- Meteor._reload.onMigrate('session', function () {
- // XXX sanitize and make sure it's JSONible?
- return [true, {keys: Session.keys}];
});
- (function () {
- var migrationData = Meteor._reload.migrationData('session');
- if (migrationData && migrationData.keys) {
- Session.keys = migrationData.keys;
- }
- })();
-}
+
+ if (Meteor._reload) {
+ Meteor._reload.onMigrate('session', function () {
+ // XXX sanitize and make sure it's JSONible?
+ return [true, {keys: Session.keys}];
+ });
+
+ (function () {
+ var migrationData = Meteor._reload.migrationData('session');
+ if (migrationData && migrationData.keys) {
+ Session.keys = migrationData.keys;
+ }
+ })();
+ }
+
+}());
diff --git a/packages/session/session_tests.js b/packages/session/session_tests.js
new file mode 100644
index 0000000000..0a750b4086
--- /dev/null
+++ b/packages/session/session_tests.js
@@ -0,0 +1,158 @@
+(function () {
+
+ Tinytest.add('session - get/set/equals types', function (test) {
+ test.equal(Session.get('u'), undefined);
+ test.isTrue(Session.equals('u', undefined));
+ test.isFalse(Session.equals('u', null));
+ test.isFalse(Session.equals('u', 0));
+ test.isFalse(Session.equals('u', ''));
+
+ Session.set('u', undefined);
+ test.equal(Session.get('u'), undefined);
+ test.isTrue(Session.equals('u', undefined));
+ test.isFalse(Session.equals('u', null));
+ test.isFalse(Session.equals('u', 0));
+ test.isFalse(Session.equals('u', ''));
+ test.isFalse(Session.equals('u', 'undefined'));
+ test.isFalse(Session.equals('u', 'null'));
+
+ Session.set('n', null);
+ test.equal(Session.get('n'), null);
+ test.isFalse(Session.equals('n', undefined));
+ test.isTrue(Session.equals('n', null));
+ test.isFalse(Session.equals('n', 0));
+ test.isFalse(Session.equals('n', ''));
+ test.isFalse(Session.equals('n', 'undefined'));
+ test.isFalse(Session.equals('n', 'null'));
+
+ Session.set('t', true);
+ test.equal(Session.get('t'), true);
+ test.isTrue(Session.equals('t', true));
+ test.isFalse(Session.equals('t', false));
+ test.isFalse(Session.equals('t', 1));
+ test.isFalse(Session.equals('t', 'true'));
+
+ Session.set('f', false);
+ test.equal(Session.get('f'), false);
+ test.isFalse(Session.equals('f', true));
+ test.isTrue(Session.equals('f', false));
+ test.isFalse(Session.equals('f', 1));
+ test.isFalse(Session.equals('f', 'false'));
+
+ Session.set('num', 0);
+ test.equal(Session.get('num'), 0);
+ test.isTrue(Session.equals('num', 0));
+ test.isFalse(Session.equals('num', false));
+ test.isFalse(Session.equals('num', '0'));
+ test.isFalse(Session.equals('num', 1));
+
+ Session.set('str', 'true');
+ test.equal(Session.get('str'), 'true');
+ test.isTrue(Session.equals('str', 'true'));
+ test.isFalse(Session.equals('str', true));
+
+ Session.set('arr', [1, 2, {a: 1, b: [5, 6]}]);
+ test.equal(Session.get('arr'), [1, 2, {b: [5, 6], a: 1}]);
+ test.isFalse(Session.equals('arr', 1));
+ test.isFalse(Session.equals('arr', '[1,2,{"a":1,"b":[5,6]}]'));
+ test.throws(function () {
+ Session.equals('arr', [1, 2, {a: 1, b: [5, 6]}]);
+ });
+
+ Session.set('obj', {a: 1, b: [5, 6]});
+ test.equal(Session.get('obj'), {b: [5, 6], a: 1});
+ test.isFalse(Session.equals('obj', 1));
+ test.isFalse(Session.equals('obj', '{"a":1,"b":[5,6]}'));
+ test.throws(function() { Session.equals('obj', {a: 1, b: [5, 6]}); });
+ });
+
+ Tinytest.add('session - objects are cloned', function (test) {
+ Session.set('frozen-array', [1, 2, 3]);
+ Session.get('frozen-array')[1] = 42;
+ test.equal(Session.get('frozen-array'), [1, 2, 3]);
+
+ Session.set('frozen-object', {a: 1, b: 2});
+ Session.get('frozen-object').a = 43;
+ test.equal(Session.get('frozen-object'), {a: 1, b: 2});
+ });
+
+ Tinytest.add('session - context invalidation for get', function (test) {
+ var xGetExecutions = 0;
+ Meteor._autorun(function () {
+ ++xGetExecutions;
+ Session.get('x');
+ });
+ test.equal(xGetExecutions, 1);
+ Session.set('x', 1);
+ // Invalidation shouldn't happen until flush time.
+ test.equal(xGetExecutions, 1);
+ Meteor.flush();
+ test.equal(xGetExecutions, 2);
+ // Setting to the same value doesn't re-run.
+ Session.set('x', 1);
+ Meteor.flush();
+ test.equal(xGetExecutions, 2);
+ Session.set('x', '1');
+ Meteor.flush();
+ test.equal(xGetExecutions, 3);
+ });
+
+ Tinytest.add('session - context invalidation for equals', function (test) {
+ var xEqualsExecutions = 0;
+ Meteor._autorun(function () {
+ ++xEqualsExecutions;
+ Session.equals('x', 5);
+ });
+ test.equal(xEqualsExecutions, 1);
+ Session.set('x', 1);
+ Meteor.flush();
+ // Changing undefined -> 1 shouldn't affect equals(5).
+ test.equal(xEqualsExecutions, 1);
+ Session.set('x', 5);
+ // Invalidation shouldn't happen until flush time.
+ test.equal(xEqualsExecutions, 1);
+ Meteor.flush();
+ test.equal(xEqualsExecutions, 2);
+ Session.set('x', 5);
+ Meteor.flush();
+ // Setting to the same value doesn't re-run.
+ test.equal(xEqualsExecutions, 2);
+ Session.set('x', '5');
+ test.equal(xEqualsExecutions, 2);
+ Meteor.flush();
+ test.equal(xEqualsExecutions, 3);
+ Session.set('x', 5);
+ test.equal(xEqualsExecutions, 3);
+ Meteor.flush();
+ test.equal(xEqualsExecutions, 4);
+ });
+
+ Tinytest.add(
+ 'session - context invalidation for equals with undefined',
+ function (test) {
+ // Make sure the special casing for equals undefined works.
+ var yEqualsExecutions = 0;
+ Meteor._autorun(function () {
+ ++yEqualsExecutions;
+ Session.equals('y', undefined);
+ });
+ test.equal(yEqualsExecutions, 1);
+ Session.set('y', undefined);
+ Meteor.flush();
+ test.equal(yEqualsExecutions, 1);
+ Session.set('y', 5);
+ test.equal(yEqualsExecutions, 1);
+ Meteor.flush();
+ test.equal(yEqualsExecutions, 2);
+ Session.set('y', 3);
+ Meteor.flush();
+ test.equal(yEqualsExecutions, 2);
+ Session.set('y', 'undefined');
+ Meteor.flush();
+ test.equal(yEqualsExecutions, 2);
+ Session.set('y', undefined);
+ test.equal(yEqualsExecutions, 2);
+ Meteor.flush();
+ test.equal(yEqualsExecutions, 3);
+ });
+}());