Skip to content
This repository
Browse code

Change interface for determining if the user doc is loaded to a new r…

…eactive

function Meteor.userLoaded(), which is true if you are logged in and the user
doc is loaded, and a currentUserLoaded Handlebars helper to match.

If logged in and the user doc is not yet loaded, Meteor.user() now returns an
object which only contains _id.

The current user subscription is now named meteor.currentUser rather than being
an unnamed sub. (loginServiceConfiguration is renamed
meteor.loginServiceConfiguration to match.) This subscription is sub'd from when
you log in and unsub'd from when you log out (or if you log in with different
credentials).

I was very careful to make sure that in the case of "sub #1, unsub #1, sub #2,
sub #1 is ready" we do not declare the user to be ready. I could have instead
modified livedata_connection to not call ready callbacks for unsub'd
subscriptions (add a "delete self.sub_ready_callbacks[obj._id]" to the self.subs
removed function) but this seemed less invasive.

The password and email tests use this to take a more rigorous approach to
waiting for the data to load, and they change the localStorage keys so that
multiple tabs running tests don't interact via localStorage.
  • Loading branch information...
commit 0f3c44b8c2d8b647d8d4b95510ad4c58dc8b699d 1 parent 3720106
David Glasser glasser authored
74 packages/accounts-base/accounts_client.js
@@ -4,23 +4,64 @@
4 4 return Meteor.default_connection.userId();
5 5 };
6 6
  7 + var userLoadedListeners = new Meteor.deps._ContextSet;
  8 + var currentUserSubscriptionData;
  9 +
  10 + Meteor.userLoaded = function () {
  11 + userLoadedListeners.addCurrentContext();
  12 + return currentUserSubscriptionData && currentUserSubscriptionData.loaded;
  13 + };
  14 +
7 15 Meteor.user = function () {
8 16 var userId = Meteor.userId();
9   - if (userId) {
10   - var result = Meteor.users.findOne(userId);
11   - if (result) {
12   - return result;
13   - } else {
14   - // If the login method completes but new subcriptions haven't
15   - // yet been sent down to the client, this is the best we can
16   - // do
17   - return {_id: userId, loading: true};
18   - }
19   - } else {
  17 + if (!userId)
20 18 return null;
  19 + if (currentUserSubscriptionData && currentUserSubscriptionData.loaded)
  20 + return Meteor.users.findOne(userId);
  21 + // Not yet loaded: return a minimal object.
  22 + return {_id: userId};
  23 + };
  24 +
  25 + Accounts._makeClientLoggedOut = function() {
  26 + Accounts._unstoreLoginToken();
  27 + Meteor.default_connection.setUserId(null);
  28 + Meteor.default_connection.onReconnect = null;
  29 + userLoadedListeners.invalidateAll();
  30 + if (currentUserSubscriptionData) {
  31 + currentUserSubscriptionData.handle.stop();
  32 + currentUserSubscriptionData = null;
21 33 }
22 34 };
23 35
  36 + Accounts._makeClientLoggedIn = function(userId, token) {
  37 + Accounts._storeLoginToken(userId, token);
  38 + Meteor.default_connection.setUserId(userId);
  39 + Meteor.default_connection.onReconnect = function() {
  40 + Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
  41 + if (error) {
  42 + Accounts._makeClientLoggedOut();
  43 + throw error;
  44 + } else {
  45 + // nothing to do
  46 + }
  47 + });
  48 + };
  49 + userLoadedListeners.invalidateAll();
  50 + if (currentUserSubscriptionData) {
  51 + currentUserSubscriptionData.handle.stop();
  52 + }
  53 + var data = currentUserSubscriptionData = {loaded: false};
  54 + data.handle = Meteor.subscribe(
  55 + "meteor.currentUser", function () {
  56 + // Important! We use "data" here, not "currentUserSubscriptionData", so
  57 + // that if we log out and in again before this subscription is ready, we
  58 + // don't make currentUserSubscriptionData look ready just because this
  59 + // older iteration of subscribing is ready.
  60 + data.loaded = true;
  61 + userLoadedListeners.invalidateAll();
  62 + });
  63 + };
  64 +
24 65 Meteor.logout = function (callback) {
25 66 Meteor.apply('logout', [], {wait: true}, function(error, result) {
26 67 if (error) {
@@ -32,19 +73,22 @@
32 73 });
33 74 };
34 75
35   - // If we're using Handlebars, register the {{currentUser}} global
36   - // helper
37   - if (window.Handlebars) {
  76 + // If we're using Handlebars, register the {{currentUser}} and
  77 + // {{currentUserLoaded}} global helpers.
  78 + if (typeof Handlebars !== 'undefined') {
38 79 Handlebars.registerHelper('currentUser', function () {
39 80 return Meteor.user();
40 81 });
  82 + Handlebars.registerHelper('currentUserLoaded', function () {
  83 + return Meteor.userLoaded();
  84 + });
41 85 }
42 86
43 87 // XXX this can be simplified if we merge in
44 88 // https://github.com/meteor/meteor/pull/273
45 89 var loginServicesConfigured = false;
46 90 var loginServicesConfiguredListeners = new Meteor.deps._ContextSet;
47   - Meteor.subscribe("loginServiceConfiguration", function () {
  91 + Meteor.subscribe("meteor.loginServiceConfiguration", function () {
48 92 loginServicesConfigured = true;
49 93 loginServicesConfiguredListeners.invalidateAll();
50 94 });
17 packages/accounts-base/accounts_server.js
@@ -233,13 +233,22 @@
233 233 /// PUBLISHING DATA
234 234 ///
235 235
236   - // Always publish the current user's record to the client.
237   - Meteor.publish(null, function() {
  236 + // Publish the current user's record to the client.
  237 + // XXX This should just be a universal subscription, but we want to know when
  238 + // we've gotten the data after a 'login' method, which currently requires
  239 + // us to unsub, sub, and wait for onComplete. This is wasteful because
  240 + // we're actually guaranteed to have the data by the time that 'login'
  241 + // returns. But we don't expose a callback to Meteor.apply which lets us
  242 + // know when the data has been processed (ie, quiescence, or at least
  243 + // partial quiescence).
  244 + Meteor.publish("meteor.currentUser", function() {
238 245 if (this.userId())
239 246 return Meteor.users.find({_id: this.userId()},
240 247 {fields: {profile: 1, username: 1, emails: 1}});
241   - else
  248 + else {
  249 + this.complete();
242 250 return null;
  251 + }
243 252 }, {is_auto: true});
244 253
245 254 // If autopublish is on, also publish everyone else's user record.
@@ -252,7 +261,7 @@
252 261 });
253 262
254 263 // Publish all login service configuration fields other than secret.
255   - Meteor.publish("loginServiceConfiguration", function () {
  264 + Meteor.publish("meteor.loginServiceConfiguration", function () {
256 265 return Accounts.configuration.find({}, {fields: {secret: 0}});
257 266 }, {is_auto: true}); // not techincally autopublish, but stops the warning.
258 267
29 packages/accounts-base/localstorage_token.js
@@ -3,6 +3,14 @@
3 3 var loginTokenKey = "Meteor.loginToken";
4 4 var userIdKey = "Meteor.userId";
5 5
  6 + // Call this from the top level of the test file for any test that does
  7 + // logging in and out, to protect multiple tabs running the same tests
  8 + // simultaneously from interfering with each others' localStorage.
  9 + Accounts._isolateLoginTokenForTest = function () {
  10 + loginTokenKey = loginTokenKey + Meteor.uuid();
  11 + userIdKey = userIdKey + Meteor.uuid();
  12 + };
  13 +
6 14 Accounts._storeLoginToken = function(userId, token) {
7 15 localStorage.setItem(userIdKey, userId);
8 16 localStorage.setItem(loginTokenKey, token);
@@ -28,27 +36,6 @@
28 36 Accounts._storedUserId = function() {
29 37 return localStorage.getItem(userIdKey);
30 38 };
31   -
32   - Accounts._makeClientLoggedOut = function() {
33   - Accounts._unstoreLoginToken();
34   - Meteor.default_connection.setUserId(null);
35   - Meteor.default_connection.onReconnect = null;
36   - };
37   -
38   - Accounts._makeClientLoggedIn = function(userId, token) {
39   - Accounts._storeLoginToken(userId, token);
40   - Meteor.default_connection.setUserId(userId);
41   - Meteor.default_connection.onReconnect = function() {
42   - Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
43   - if (error) {
44   - Accounts._makeClientLoggedOut();
45   - throw error;
46   - } else {
47   - // nothing to do
48   - }
49   - });
50   - };
51   - };
52 39 })();
53 40
54 41 // Login with a Meteor access token
84 packages/accounts-password/email_tests.js
@@ -11,6 +11,8 @@
11 11 var validateEmailToken;
12 12 var enrollAccountToken;
13 13
  14 + Accounts._isolateLoginTokenForTest();
  15 +
14 16 testAsyncMulti("accounts emails - reset password flow", [
15 17 function (test, expect) {
16 18 email1 = Meteor.uuid() + "-intercept@example.com";
@@ -65,6 +67,7 @@
65 67
66 68 var getValidateEmailToken = function (email, test, expect) {
67 69 Meteor.call("getInterceptedEmails", email, expect(function (error, result) {
  70 + test.isFalse(error);
68 71 test.notEqual(result, undefined);
69 72 test.equal(result.length, 1);
70 73 var content = result[0];
@@ -77,15 +80,28 @@
77 80 }));
78 81 };
79 82
  83 + var waitUntilLoggedIn = function (test, expect) {
  84 + var unblockNextFunction = expect();
  85 + var quiesceCallback = function () {
  86 + Meteor._autorun(function (handle) {
  87 + if (!Meteor.userLoaded()) return;
  88 + handle.stop();
  89 + unblockNextFunction();
  90 + });
  91 + };
  92 + return expect(function (error) {
  93 + test.equal(error, undefined);
  94 + Meteor.default_connection.onQuiesce(quiesceCallback);
  95 + });
  96 + };
  97 +
80 98 testAsyncMulti("accounts emails - validate email flow", [
81 99 function (test, expect) {
82 100 email2 = Meteor.uuid() + "-intercept@example.com";
83 101 email3 = Meteor.uuid() + "-intercept@example.com";
84 102 Accounts.createUser(
85 103 {email: email2, password: 'foobar'},
86   - expect(function (error) {
87   - test.equal(error, undefined);
88   - }));
  104 + waitUntilLoggedIn(test, expect));
89 105 },
90 106 function (test, expect) {
91 107 test.equal(Meteor.user().emails.length, 1);
@@ -96,17 +112,23 @@
96 112 getValidateEmailToken(email2, test, expect);
97 113 },
98 114 function (test, expect) {
99   - Accounts.validateEmail(validateEmailToken, expect(function(error) {
100   - test.isFalse(error);
101   - }));
102   - // ARGH! ON QUIESCE!!
103   - Meteor.default_connection.onQuiesce(expect(function () {
104   - test.equal(Meteor.user().emails.length, 1);
105   - test.equal(Meteor.user().emails[0].address, email2);
106   - test.isTrue(Meteor.user().emails[0].validated);
  115 + // Log out, to test that validateEmail logs us back in. (And if we don't
  116 + // do that, waitUntilLoggedIn won't be able to prevent race conditions.)
  117 + Meteor.logout(expect(function (error) {
  118 + test.equal(error, undefined);
  119 + test.equal(Meteor.user(), null);
107 120 }));
108 121 },
109 122 function (test, expect) {
  123 + Accounts.validateEmail(validateEmailToken,
  124 + waitUntilLoggedIn(test, expect));
  125 + },
  126 + function (test, expect) {
  127 + test.equal(Meteor.user().emails.length, 1);
  128 + test.equal(Meteor.user().emails[0].address, email2);
  129 + test.isTrue(Meteor.user().emails[0].validated);
  130 + },
  131 + function (test, expect) {
110 132 Meteor.call(
111 133 "addEmailForTestAndValidate", email3,
112 134 expect(function (error, result) {
@@ -124,15 +146,20 @@
124 146 getValidateEmailToken(email3, test, expect);
125 147 },
126 148 function (test, expect) {
127   - Accounts.validateEmail(validateEmailToken, expect(function(error) {
128   - test.isFalse(error);
  149 + // Log out, to test that validateEmail logs us back in. (And if we don't
  150 + // do that, waitUntilLoggedIn won't be able to prevent race conditions.)
  151 + Meteor.logout(expect(function (error) {
  152 + test.equal(error, undefined);
  153 + test.equal(Meteor.user(), null);
129 154 }));
130 155 },
131 156 function (test, expect) {
132   - Meteor.default_connection.onQuiesce(expect(function () {
133   - test.equal(Meteor.user().emails[1].address, email3);
134   - test.isTrue(Meteor.user().emails[1].validated);
135   - }));
  157 + Accounts.validateEmail(validateEmailToken,
  158 + waitUntilLoggedIn(test, expect));
  159 + },
  160 + function (test, expect) {
  161 + test.equal(Meteor.user().emails[1].address, email3);
  162 + test.isTrue(Meteor.user().emails[1].validated);
136 163 },
137 164 function (test, expect) {
138 165 Meteor.logout(expect(function (error) {
@@ -172,16 +199,13 @@
172 199 getEnrollAccountToken(email4, test, expect);
173 200 },
174 201 function (test, expect) {
175   - Accounts.resetPassword(enrollAccountToken, 'password', expect(function(error) {
176   - test.isFalse(error);
177   - }));
  202 + Accounts.resetPassword(enrollAccountToken, 'password',
  203 + waitUntilLoggedIn(test, expect));
178 204 },
179 205 function (test, expect) {
180   - Meteor.default_connection.onQuiesce(expect(function () {
181   - test.equal(Meteor.user().emails.length, 1);
182   - test.equal(Meteor.user().emails[0].address, email4);
183   - test.isTrue(Meteor.user().emails[0].validated);
184   - }));
  206 + test.equal(Meteor.user().emails.length, 1);
  207 + test.equal(Meteor.user().emails[0].address, email4);
  208 + test.isTrue(Meteor.user().emails[0].validated);
185 209 },
186 210 function (test, expect) {
187 211 Meteor.logout(expect(function (error) {
@@ -190,9 +214,13 @@
190 214 }));
191 215 },
192 216 function (test, expect) {
193   - Meteor.loginWithPassword({email: email4}, 'password', expect(function(error) {
194   - test.isFalse(error);
195   - }));
  217 + Meteor.loginWithPassword({email: email4}, 'password',
  218 + waitUntilLoggedIn(test ,expect));
  219 + },
  220 + function (test, expect) {
  221 + test.equal(Meteor.user().emails.length, 1);
  222 + test.equal(Meteor.user().emails[0].address, email4);
  223 + test.isTrue(Meteor.user().emails[0].validated);
196 224 },
197 225 function (test, expect) {
198 226 Meteor.logout(expect(function (error) {
98 packages/accounts-password/passwords_tests.js
@@ -3,6 +3,7 @@ if (Meteor.isClient) (function () {
3 3 // XXX note, only one test can do login/logout things at once! for
4 4 // now, that is this test.
5 5
  6 + Accounts._isolateLoginTokenForTest();
6 7
7 8 var logoutStep = function (test, expect) {
8 9 Meteor.logout(expect(function (error) {
@@ -11,6 +12,25 @@ if (Meteor.isClient) (function () {
11 12 }));
12 13 };
13 14
  15 + var verifyUsername = function (someUsername, test, expect) {
  16 + var callWhenLoaded = expect(function() {
  17 + test.equal(Meteor.user().username, someUsername);
  18 + });
  19 + return function () {
  20 + Meteor._autorun(function(handle) {
  21 + if (!Meteor.userLoaded()) return;
  22 + handle.stop();
  23 + callWhenLoaded();
  24 + });
  25 + };
  26 + };
  27 + var loggedInAs = function (someUsername, test, expect) {
  28 + var quiesceCallback = verifyUsername(someUsername, test, expect);
  29 + return expect(function (error) {
  30 + test.equal(error, undefined);
  31 + Meteor.default_connection.onQuiesce(quiesceCallback);
  32 + });
  33 + };
14 34
15 35 // declare variable outside the testAsyncMulti, so we can refer to
16 36 // them from multiple tests, but initialize them to new values inside
@@ -33,46 +53,29 @@ if (Meteor.isClient) (function () {
33 53 },
34 54
35 55 function (test, expect) {
36   - // XXX argh quiescence + tests === wtf. and i have no idea why
37   - // this was necessary here and not in other places. probably
38   - // because it's dependant on how long method call chains are in
39   - // other tests
40   - var quiesceCallback = expect(function () {
41   - test.equal(Meteor.user().username, username);
42   - });
43   - Accounts.createUser({username: username, email: email, password: password},
44   - expect(function (error) {
45   - test.equal(error, undefined);
46   - Meteor.default_connection.onQuiesce(quiesceCallback);
47   - }));
  56 + Accounts.createUser(
  57 + {username: username, email: email, password: password},
  58 + loggedInAs(username, test, expect));
48 59 },
49 60 logoutStep,
50 61 function (test, expect) {
51   - Meteor.loginWithPassword(username, password, expect(function (error) {
52   - test.equal(error, undefined);
53   - test.equal(Meteor.user().username, username);
54   - }));
  62 + Meteor.loginWithPassword(username, password,
  63 + loggedInAs(username, test, expect));
55 64 },
56 65 logoutStep,
57 66 function (test, expect) {
58   - Meteor.loginWithPassword({username: username}, password, expect(function (error) {
59   - test.equal(error, undefined);
60   - test.equal(Meteor.user().username, username);
61   - }));
  67 + Meteor.loginWithPassword({username: username}, password,
  68 + loggedInAs(username, test, expect));
62 69 },
63 70 logoutStep,
64 71 function (test, expect) {
65   - Meteor.loginWithPassword(email, password, expect(function (error) {
66   - test.equal(error, undefined);
67   - test.equal(Meteor.user().username, username);
68   - }));
  72 + Meteor.loginWithPassword(email, password,
  73 + loggedInAs(username, test, expect));
69 74 },
70 75 logoutStep,
71 76 function (test, expect) {
72   - Meteor.loginWithPassword({email: email}, password, expect(function (error) {
73   - test.equal(error, undefined);
74   - test.equal(Meteor.user().username, username);
75   - }));
  77 + Meteor.loginWithPassword({email: email}, password,
  78 + loggedInAs(username, test, expect));
76 79 },
77 80 logoutStep,
78 81 // plain text password. no API for this, have to send a raw message.
@@ -87,6 +90,7 @@ if (Meteor.isClient) (function () {
87 90 }));
88 91 },
89 92 function (test, expect) {
  93 + var quiesceCallback = verifyUsername(username, test, expect);
90 94 Meteor.call(
91 95 // right password
92 96 'login', {user: {email: email}, password: password},
@@ -96,22 +100,21 @@ if (Meteor.isClient) (function () {
96 100 test.isTrue(result.token);
97 101 // emulate the real login behavior, so as not to confuse test.
98 102 Accounts._makeClientLoggedIn(result.id, result.token);
99   - test.equal(Meteor.user().username, username);
  103 + Meteor.default_connection.onQuiesce(quiesceCallback);
100 104 }));
101 105 },
102   - // change password with bad old password.
  106 + // change password with bad old password. we stay logged in.
103 107 function (test, expect) {
  108 + var quiesceCallback = verifyUsername(username, test, expect);
104 109 Accounts.changePassword(password2, password2, expect(function (error) {
105 110 test.isTrue(error);
106   - test.equal(Meteor.user().username, username);
  111 + Meteor.default_connection.onQuiesce(quiesceCallback);
107 112 }));
108 113 },
109 114 // change password with good old password.
110 115 function (test, expect) {
111   - Accounts.changePassword(password, password2, expect(function (error) {
112   - test.equal(error, undefined);
113   - test.equal(Meteor.user().username, username);
114   - }));
  116 + Accounts.changePassword(password, password2,
  117 + loggedInAs(username, test, expect));
115 118 },
116 119 logoutStep,
117 120 // old password, failed login
@@ -123,14 +126,13 @@ if (Meteor.isClient) (function () {
123 126 },
124 127 // new password, success
125 128 function (test, expect) {
126   - Meteor.loginWithPassword(email, password2, expect(function (error) {
127   - test.equal(error, undefined);
128   - test.equal(Meteor.user().username, username);
129   - }));
  129 + Meteor.loginWithPassword(email, password2,
  130 + loggedInAs(username, test, expect));
130 131 },
131 132 logoutStep,
132 133 // create user with raw password
133 134 function (test, expect) {
  135 + var quiesceCallback = verifyUsername(username2, test, expect);
134 136 Meteor.call('createUser', {username: username2, password: password2},
135 137 expect(function (error, result) {
136 138 test.equal(error, undefined);
@@ -138,16 +140,13 @@ if (Meteor.isClient) (function () {
138 140 test.isTrue(result.token);
139 141 // emulate the real login behavior, so as not to confuse test.
140 142 Accounts._makeClientLoggedIn(result.id, result.token);
141   - test.equal(Meteor.user().username, username2);
  143 + Meteor.default_connection.onQuiesce(quiesceCallback);
142 144 }));
143 145 },
144 146 logoutStep,
145 147 function(test, expect) {
146 148 Meteor.loginWithPassword({username: username2}, password2,
147   - expect(function (error) {
148   - test.equal(error, undefined);
149   - test.equal(Meteor.user().username, username2);
150   - }));
  149 + loggedInAs(username2, test, expect));
151 150 },
152 151 logoutStep,
153 152 // test Accounts.validateNewUser
@@ -173,10 +172,13 @@ if (Meteor.isClient) (function () {
173 172 },
174 173 // test Accounts.onCreateUser
175 174 function(test, expect) {
176   - Accounts.createUser({username: username3, password: password3},
177   - {testOnCreateUserHook: true}, expect(function () {
178   - test.equal(Meteor.user().profile.touchedByOnCreateUser, true);
179   - }));
  175 + Accounts.createUser(
  176 + {username: username3, password: password3},
  177 + {testOnCreateUserHook: true},
  178 + loggedInAs(username3, test, expect));
  179 + },
  180 + function(test, expect) {
  181 + test.equal(Meteor.user().profile.touchedByOnCreateUser, true);
180 182 },
181 183
182 184 // test Meteor.user(). This test properly belongs in
6 packages/accounts-ui-unstyled/login_buttons.html
@@ -13,10 +13,10 @@
13 13 {{> loginButtonsLoggedInDropdown}}
14 14 {{else}}
15 15 <div class="login-header">
16   - {{#if currentUser.loading}} {{! XXX this will change }}
17   - <div class="loading"></div>
18   - {{else}}
  16 + {{#if currentUserLoaded}}
19 17 {{displayName}}
  18 + {{else}}
  19 + <div class="loading"></div>
20 20 {{/if}}
21 21 </div>
22 22 <div class="login-button" id="login-buttons-logout">Logout</div>
6 packages/accounts-ui-unstyled/login_buttons_dropdown.html
@@ -3,9 +3,7 @@
3 3 <!-- -->
4 4 <template name="loginButtonsLoggedInDropdown">
5 5 <div class="login-link-and-dropdown-list">
6   - {{#if currentUser.loading}}
7   - <div class="loading"></div>
8   - {{else}}
  6 + {{#if currentUserLoaded}}
9 7 <a class="login-link-text" id="login-name-link">
10 8 {{displayName}} ▾
11 9 </a>
@@ -22,6 +20,8 @@
22 20 {{/if}}
23 21 </div>
24 22 {{/if}}
  23 + {{else}}
  24 + <div class="loading"></div>
25 25 {{/if}}
26 26 </div>
27 27 </template>

0 comments on commit 0f3c44b

Please sign in to comment.
Something went wrong with that request. Please try again.