web-manager 4.1.9 → 4.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ ---
18
+ ## [4.1.10] - 2026-02-17
19
+ ### Changed
20
+ - Refactored auth to use promise-based settler pattern (`_authReady`) for reliable auth state detection, eliminating race conditions with late-registered listeners.
21
+ - Split `listen()` into two clear paths: `once` (waits for settler, fires once) and persistent (subscribes to all changes, catches up if already settled).
22
+ - Renamed `onAuthStateChanged` to `_subscribe` (internal-only).
23
+ - Removed unused `_readyCallbacks`.
24
+
17
25
  ---
18
26
  ## [4.1.1] - 2025-12-17
19
27
  ### Added
package/CLAUDE.md CHANGED
@@ -150,6 +150,14 @@ document.body.addEventListener('click', (e) => {
150
150
  - **Key Methods**: `listen(options, callback)`, `isAuthenticated()`, `getUser()`, `signInWithEmailAndPassword()`, `signOut()`, `getIdToken()`
151
151
  - **Bindings**: Updates `auth.user` and `auth.account` context
152
152
 
153
+ #### Auth Settler Pattern
154
+ Auth uses a promise-based settler (`_authReady`) that resolves once Firebase's first `onAuthStateChanged` fires — the moment auth state is guaranteed (authenticated user OR null). This eliminates race conditions.
155
+
156
+ - **`once` listeners** (`listen({ once: true }, cb)`): Wait for `_authReady`, fire once, done. No cleanup needed.
157
+ - **Persistent listeners** (`listen({}, cb)`): Subscribe to `_authStateCallbacks`. If auth already settled when registered, catch up via `_authReady.then()`. Otherwise, `_handleAuthStateChange` handles the initial call naturally.
158
+ - **`_hasProcessedStateChange`**: Ensures bindings/storage updates run only once per auth state change across all listeners.
159
+ - **Manager owns the promise**: `_authReady` and `_authReadyResolve` live on the Manager instance. The `onAuthStateChanged` callback in `index.js` resolves it on first fire and sets `_firebaseAuthInitialized = true`.
160
+
153
161
  ### Bindings (`bindings.js`)
154
162
  - **Class**: `Bindings`
155
163
  - **Key Methods**: `update(data)`, `getContext()`, `clear()`
package/README.md CHANGED
@@ -296,20 +296,24 @@ storage.session.clear();
296
296
 
297
297
  ### Authentication
298
298
 
299
- Firebase Authentication wrapper with automatic account data fetching:
299
+ Firebase Authentication wrapper with a promise-based auth settler:
300
300
 
301
301
  ```javascript
302
302
  const auth = Manager.auth();
303
303
 
304
- // Listen for auth state changes
305
- const unsubscribe = auth.listen({ account: true }, (result) => {
306
- console.log('User:', result.user); // Firebase user or null
307
- console.log('Account:', result.account); // Firestore account data or null
304
+ // Listen once — waits for auth to settle, fires exactly once
305
+ auth.listen({ once: true }, (state) => {
306
+ if (state.user) {
307
+ console.log('Logged in:', state.user.email);
308
+ console.log('Account:', state.account);
309
+ } else {
310
+ console.log('Not logged in');
311
+ }
308
312
  });
309
313
 
310
- // Listen once (useful for initial state)
311
- auth.listen({ once: true }, (result) => {
312
- console.log('Initial state:', result);
314
+ // Persistent listener fires on initial settle + every future auth change
315
+ const unsubscribe = auth.listen({}, (state) => {
316
+ console.log('Auth changed:', state.user?.email || 'signed out');
313
317
  });
314
318
 
315
319
  // Check authentication status
@@ -319,11 +323,7 @@ if (auth.isAuthenticated()) {
319
323
  }
320
324
 
321
325
  // Sign in
322
- try {
323
- const user = await auth.signInWithEmailAndPassword('user@example.com', 'password');
324
- } catch (error) {
325
- console.error('Sign in failed:', error.message);
326
- }
326
+ await auth.signInWithEmailAndPassword('user@example.com', 'password');
327
327
 
328
328
  // Sign in with custom token (from backend)
329
329
  await auth.signInWithCustomToken('custom-jwt-token');
@@ -335,10 +335,17 @@ const freshToken = await auth.getIdToken(true); // Force refresh
335
335
  // Sign out
336
336
  await auth.signOut();
337
337
 
338
- // Stop listening
338
+ // Stop persistent listener
339
339
  unsubscribe();
340
340
  ```
341
341
 
342
+ **Auth Settler Design**:
343
+
344
+ On page load, Firebase Auth takes time to restore the user session. The auth settler (`Manager._authReady`) is a promise that resolves once Firebase determines the auth state (user or null). All `listen()` callbacks wait for this settler before firing — consumers never see an intermediate/unknown state.
345
+
346
+ - `{ once: true }` — Waits for the settler promise, calls the callback once, done. No cleanup needed.
347
+ - `{}` (persistent) — Gets the initial settled state via `_handleAuthStateChange`, then fires again on every future sign-in/sign-out.
348
+
342
349
  **getUser() returns enhanced user object**:
343
350
  ```javascript
344
351
  {
@@ -351,38 +358,24 @@ unsubscribe();
351
358
  ```
352
359
 
353
360
  **HTML Auth Classes**:
354
- Add these classes to elements for automatic auth functionality:
355
361
  - `.auth-signout-btn` - Sign out button (shows confirmation dialog)
356
362
 
357
363
  **⚠️ Auth State Timing**:
358
364
 
359
- On fresh page loads, Firebase Auth needs time to restore the user session from IndexedDB/localStorage. Methods like `auth.isAuthenticated()`, `auth.getUser()`, and `auth.getIdToken()` may return `null`/`false` if called before auth state is determined.
365
+ Methods like `auth.isAuthenticated()`, `auth.getUser()`, and `auth.getIdToken()` read the current state directly — they may return `null` before auth settles.
360
366
 
361
- **Problem:**
362
367
  ```javascript
363
368
  // ❌ May fail on page load - auth state not yet determined
364
- await Manager.dom().ready();
365
- const token = await auth.getIdToken(); // Could throw if currentUser is null
366
- ```
369
+ const token = await auth.getIdToken();
367
370
 
368
- **Solution:** Use `auth.listen({ once: true })` to wait for auth state:
369
- ```javascript
370
- // ✅ Wait for auth state to be determined first
371
- auth.listen({ once: true }, async (result) => {
372
- if (result.user) {
373
- const token = await auth.getIdToken(); // Safe - user is authenticated
371
+ // Wait for auth to settle first
372
+ auth.listen({ once: true }, async (state) => {
373
+ if (state.user) {
374
+ const token = await auth.getIdToken(); // Safe
374
375
  }
375
376
  });
376
377
  ```
377
378
 
378
- **When this matters:**
379
- - Pages making authenticated API calls immediately on load
380
- - OAuth callback pages
381
- - Deep links requiring authentication
382
-
383
- **When NOT needed:**
384
- - User-triggered actions (button clicks) - auth state is always determined by then
385
-
386
379
  ### Data Binding System
387
380
 
388
381
  Reactive DOM updates with `data-wm-bind` attributes:
package/TODO.md CHANGED
@@ -7,6 +7,9 @@ Do we need to use polyfill? the project that consumes this is using webpack and
7
7
  - async
8
8
 
9
9
 
10
+ Eventually update auth.js to have an intelligent schema for user object
11
+
12
+
10
13
  UTM management
11
14
 
12
15
  LOCALSTORAGE
package/dist/index.js CHANGED
@@ -20,8 +20,12 @@ class Manager {
20
20
  serviceWorker: null
21
21
  };
22
22
 
23
- // Track Firebase auth initialization
23
+ // Auth settler: resolves when Firebase auth first determines user state
24
24
  this._firebaseAuthInitialized = false;
25
+ this._authReadyResolve = null;
26
+ this._authReady = new Promise((resolve) => {
27
+ this._authReadyResolve = resolve;
28
+ });
25
29
 
26
30
  // Initialize modules
27
31
  this._storage = new Storage();
@@ -419,8 +423,11 @@ class Manager {
419
423
 
420
424
  // Setup auth state listener
421
425
  onAuthStateChanged(this._firebaseAuth, (user) => {
422
- // Mark auth as initialized after first callback
423
- this._firebaseAuthInitialized = true;
426
+ // Mark auth as initialized and resolve the settler promise on first callback
427
+ if (!this._firebaseAuthInitialized) {
428
+ this._firebaseAuthInitialized = true;
429
+ this._authReadyResolve();
430
+ }
424
431
 
425
432
  // Let auth module handle everything including DOM updates
426
433
  this._auth._handleAuthStateChange(user);
@@ -27,6 +27,10 @@ const DEFAULT_ACCOUNT = {
27
27
  usage: { requests: { total: 0, period: 0 } },
28
28
  personal: { name: { first: null, last: null } },
29
29
  oauth2: {},
30
+ attribution: {
31
+ affiliate: { code: null, timestamp: null, url: null, page: null },
32
+ utm: { tags: {}, timestamp: null, url: null, page: null },
33
+ },
30
34
  };
31
35
 
32
36
  function resolveAccount(rawData, firebaseUser) {
@@ -62,7 +66,6 @@ class Auth {
62
66
  constructor(manager) {
63
67
  this.manager = manager;
64
68
  this._authStateCallbacks = [];
65
- this._readyCallbacks = [];
66
69
  this._hasProcessedStateChange = false;
67
70
  }
68
71
 
@@ -128,89 +131,66 @@ class Auth {
128
131
 
129
132
  // If Firebase is not enabled, call callback immediately with null
130
133
  if (!this.manager.config.firebase?.app?.enabled) {
131
- // Call callback with null user and empty account
132
134
  callback({
133
135
  user: null,
134
- account: resolveAccount({}, {})
136
+ account: resolveAccount({}, {}),
135
137
  });
136
138
 
137
- // Return empty unsubscribe function
138
139
  return () => {};
139
140
  }
140
141
 
141
- // Function to get current state and call callback
142
- const getStateAndCallback = async (user) => {
143
- // Start with the user which will return null if not authenticated
142
+ // Build auth state and call the provided callback
143
+ const run = async (user) => {
144
144
  const state = { user: this.getUser() };
145
145
 
146
- // Then, add account data if requested and user exists
147
- // if (options.account && user && this.manager.firebaseFirestore) {
148
- // Fetch account if the user is logged in AND Firestore is available
146
+ // Fetch account data if the user is logged in and Firestore is available
149
147
  if (user && this.manager.firebaseFirestore) {
150
148
  try {
151
149
  state.account = await this._getAccountData(user.uid);
152
150
  } catch (error) {
153
- // Pass error to Sentry
154
151
  this.manager.sentry().captureException(new Error('Failed to get account data', { cause: error }));
155
152
  }
156
153
  }
157
154
 
158
- // Always ensure account is at least a default resolved object
155
+ // Ensure account is always a resolved object
159
156
  state.account = state.account || resolveAccount({}, { uid: user?.uid });
160
157
 
161
- // Process state change (update bindings and storage) only once across all callbacks
162
- // Now ONLY the first listener will process the state change until the next auth state change
158
+ // Update bindings and storage once per auth state change
163
159
  if (!this._hasProcessedStateChange) {
164
- // Run update - nest state under 'auth' key for consistent access
165
160
  this.manager.bindings().update({ auth: state });
166
-
167
- // Save to storage
168
- const storage = this.manager.storage();
169
- storage.set('auth', state);
170
-
171
- // Mark that we've processed this state change
161
+ this.manager.storage().set('auth', state);
172
162
  this._hasProcessedStateChange = true;
173
163
  }
174
164
 
175
- // Call the provided callback with the state
176
165
  callback(state);
177
166
  };
178
167
 
179
- let hasCalledback = false;
180
- let unsubscribe = null;
181
-
182
- // Set up listener for auth state changes
183
- unsubscribe = this.onAuthStateChanged((user) => {
184
- // Wait for settled state before first callback
185
- if (!hasCalledback && !this.manager._firebaseAuthInitialized) {
186
- return; // Auth state not yet determined
187
- }
168
+ // Once listeners: wait for auth to settle, fire once, done
169
+ if (options.once) {
170
+ this.manager._authReady.then(() => {
171
+ run(this.manager.firebaseAuth?.currentUser || null);
172
+ });
188
173
 
189
- // Mark that we've called back at least once
190
- hasCalledback = true;
174
+ return () => {};
175
+ }
191
176
 
192
- // Get current state and call the callback
193
- getStateAndCallback(user);
177
+ // Persistent listeners: subscribe to all auth state changes (initial + future)
178
+ // If auth already settled, fire the first callback via the promise to catch up
179
+ const unsubscribe = this._subscribe(run);
194
180
 
195
- // If once option is set, unsubscribe AFTER calling the callback
196
- if (options.once && unsubscribe) {
197
- unsubscribe();
198
- }
199
- });
181
+ if (this.manager._firebaseAuthInitialized) {
182
+ this.manager._authReady.then(() => {
183
+ run(this.manager.firebaseAuth?.currentUser || null);
184
+ });
185
+ }
200
186
 
201
187
  return unsubscribe;
202
188
  }
203
189
 
204
- // Listen for auth state changes
205
- onAuthStateChanged(callback) {
190
+ // Subscribe to ongoing auth state changes (sign-in, sign-out after initial settle)
191
+ _subscribe(callback) {
206
192
  this._authStateCallbacks.push(callback);
207
193
 
208
- // If auth is already initialized, call the callback immediately
209
- if (this.manager._firebaseAuthInitialized) {
210
- callback(this.manager.firebaseAuth?.currentUser || null);
211
- }
212
-
213
- // Return unsubscribe function
214
194
  return () => {
215
195
  const index = this._authStateCallbacks.indexOf(callback);
216
196
  if (index > -1) {
@@ -219,12 +199,12 @@ class Auth {
219
199
  };
220
200
  }
221
201
 
222
- // Internal method to handle auth state changes
202
+ // Called by Manager when Firebase auth state changes
223
203
  _handleAuthStateChange(user) {
224
204
  // Reset state processing flag for new auth state
225
205
  this._hasProcessedStateChange = false;
226
206
 
227
- // Call all registered callbacks
207
+ // Call all persistent listener callbacks
228
208
  this._authStateCallbacks.forEach(callback => {
229
209
  try {
230
210
  callback(user);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-manager",
3
- "version": "4.1.9",
3
+ "version": "4.1.11",
4
4
  "description": "Easily access important variables such as the query string, current domain, and current page in a single object.",
5
5
  "main": "dist/index.js",
6
6
  "module": "src/index.js",
@@ -42,7 +42,7 @@
42
42
  "@sentry/browser": "Resolved by using OVERRIDES in web-manager (lighthouse is the issue"
43
43
  },
44
44
  "dependencies": {
45
- "@sentry/browser": "^10.38.0",
45
+ "@sentry/browser": "^10.39.0",
46
46
  "firebase": "^12.9.0",
47
47
  "itwcw-package-analytics": "^1.0.8",
48
48
  "lodash": "^4.17.23"