@viibestack/ui 0.6.4 → 0.6.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/auth.ts +38 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viibestack/ui",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), a real per-app data client (listRows/upsertRow/deleteRow/captureClientError), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/auth.ts CHANGED
@@ -171,6 +171,39 @@ export async function logOut(): Promise<void> {
171
171
  await callAppAuth("/logout", { method: "POST", body: JSON.stringify({ session_id: stored.sessionId }) });
172
172
  }
173
173
 
174
+ // Single-flight guard for the refresh call below -- without this, calling
175
+ // getCurrentUser() from more than one place on the same page load (a nav
176
+ // component AND a page component each checking auth in their own
177
+ // useEffect, a completely normal shape for a generated app) fires two
178
+ // concurrent /session/refresh calls with the SAME still-stored session_id.
179
+ // The auth service rotates that id on every refresh (see services/auth's
180
+ // own comment on that route), so only the first of the two UPDATEs
181
+ // actually matches a row -- the second gets back "session expired" even
182
+ // though the first one just succeeded, and its `saveStoredSession(null)`
183
+ // then wipes out the perfectly valid session the first call just saved,
184
+ // logging a real signed-in user out. Confirmed as the root cause of a
185
+ // real_accounts logout report (2026-07-19) -- funneling every concurrent
186
+ // caller through the SAME in-flight promise means only one refresh request
187
+ // is ever sent for a given expiring token, regardless of how many
188
+ // components independently call getCurrentUser() at once. Does not cover
189
+ // two separate browser TABS refreshing at once (each has its own module
190
+ // state) -- that's a rarer trigger and would need cross-tab coordination
191
+ // this fix doesn't attempt.
192
+ let refreshInFlight: Promise<AppUser | null> | null = null;
193
+
194
+ async function refreshCurrentSession(sessionId: string): Promise<AppUser | null> {
195
+ const result = await callAppAuth<SessionResponse>("/session/refresh", {
196
+ method: "POST",
197
+ body: JSON.stringify({ session_id: sessionId }),
198
+ });
199
+ if (!result.ok) {
200
+ saveStoredSession(null);
201
+ return null;
202
+ }
203
+ saveStoredSession(result.body);
204
+ return result.body.user;
205
+ }
206
+
174
207
  // The one function generated app code is expected to call on every page
175
208
  // load to find out "is anyone logged in" -- returns null if there's no
176
209
  // session, or if the session has expired/been revoked server-side (in
@@ -183,16 +216,12 @@ export async function getCurrentUser(): Promise<AppUser | null> {
183
216
  const needsRefresh = exp == null || exp - Math.floor(Date.now() / 1000) < 120;
184
217
  if (!needsRefresh) return stored.user;
185
218
 
186
- const result = await callAppAuth<SessionResponse>("/session/refresh", {
187
- method: "POST",
188
- body: JSON.stringify({ session_id: stored.sessionId }),
189
- });
190
- if (!result.ok) {
191
- saveStoredSession(null);
192
- return null;
219
+ if (!refreshInFlight) {
220
+ refreshInFlight = refreshCurrentSession(stored.sessionId).finally(() => {
221
+ refreshInFlight = null;
222
+ });
193
223
  }
194
- saveStoredSession(result.body);
195
- return result.body.user;
224
+ return refreshInFlight;
196
225
  }
197
226
 
198
227
  export async function requestPasswordReset(email: string): Promise<ActionResult> {