Skip to content

Commit 35749bf

Browse files
vsanseclaude
andcommitted
feat(banner): sync browserstackId live so the migration banner self-clears (RQ-5181)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5c7d2b6 commit 35749bf

4 files changed

Lines changed: 59 additions & 2 deletions

File tree

app/src/componentsV2/AppNotificationBanner/hooks/useBannerVisibility.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { PRICING } from "features/pricing/constants/pricing";
66
import { getUserAuthDetails } from "store/slices/global/user/selectors";
77
import { getAvailableBillingTeams } from "store/features/billing/selectors";
88
import { useFeatureValue } from "@growthbook/growthbook-react";
9-
import { getAppNotificationBannerDismissTs } from "store/selectors";
9+
import { getAppNotificationBannerDismissTs, getUserBrowserstackId } from "store/selectors";
1010

1111
export const useBannerVisibility = (bannerId: string): boolean => {
1212
const user = useSelector(getUserAuthDetails);
1313
const billingTeams = useSelector(getAvailableBillingTeams);
1414
const lastDismissTs = useSelector(getAppNotificationBannerDismissTs);
15+
const browserstackId = useSelector(getUserBrowserstackId);
1516
const allBanners = useFeatureValue("app_banner", []);
1617
const newBanners = useMemo(() => allBanners.filter((banner: any) => banner.createdTs > (lastDismissTs || 0)), [
1718
allBanners,
@@ -63,8 +64,14 @@ export const useBannerVisibility = (bannerId: string): boolean => {
6364
return isTeamOwner;
6465
}
6566

67+
case BANNER_ID.LOGIN_MIGRATION:
68+
// `undefined` means the user doc has not been read yet, `null` means it was read and the
69+
// user has no BrowserStack account. Only the second should show the banner, otherwise an
70+
// already-merged user sees it flash at every cold start.
71+
return Boolean(user?.loggedIn) && browserstackId === null;
72+
6673
default:
6774
return true;
6875
}
69-
}, [bannerId, user, billingTeams, newBanners]);
76+
}, [bannerId, user, billingTeams, newBanners, browserstackId]);
7077
};

app/src/hooks/DbListenerInit/DBListeners.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getAppMode, getAuthInitialization } from "../../store/selectors";
44
import { getUserAuthDetails } from "store/slices/global/user/selectors";
55
import syncingNodeListener from "./syncingNodeListener";
66
import userNodeListener from "./userNodeListener";
7+
import userDocListener from "./userDocListener";
78
import { globalActions } from "store/slices/global/slice";
89
import { isArray } from "lodash";
910
import { useHasChanged } from "hooks/useHasChanged";
@@ -21,15 +22,18 @@ const DBListeners = () => {
2122
const hasAuthInitialized = useSelector(getAuthInitialization);
2223

2324
let unsubscribeUserNodeRef = useRef(null);
25+
let unsubscribeUserDocRef = useRef(null);
2426
window.unsubscribeSyncingNodeRef = useRef(null);
2527

2628
const hasAuthStateChanged = useHasChanged(user?.loggedIn);
2729

2830
// Listens to /users/{id} changes
2931
useEffect(() => {
3032
if (unsubscribeUserNodeRef.current) unsubscribeUserNodeRef.current(); // Unsubscribe existing user node listener before creating a new one
33+
if (unsubscribeUserDocRef.current) unsubscribeUserDocRef.current();
3134
if (user?.loggedIn) {
3235
unsubscribeUserNodeRef.current = userNodeListener(dispatch, user?.details?.profile.uid, appMode);
36+
unsubscribeUserDocRef.current = userDocListener(user?.details?.profile.uid);
3337
/* CAN BE MOVED TO SEPARATE USE EFFECT AND SHOULD HAVE AN UNSUBSCRIBER TOO, will be useful when actually implementing premium */
3438
userSubscriptionDocListener(dispatch, user?.details?.profile.uid);
3539
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { doc, getFirestore, onSnapshot } from "firebase/firestore";
2+
import firebaseApp from "../../firebase";
3+
import APP_CONSTANTS from "config/constants";
4+
import { submitAttrUtil } from "utils/AnalyticsUtils";
5+
import Logger from "lib/logger";
6+
7+
/**
8+
* Keeps the GrowthBook attribute `browserstack_id` in step with the Firestore user doc.
9+
* The BrowserStack merge writes `browserstackId` from a different browser when the app runs on
10+
* desktop, and AuthHandler only reads it once from onAuthStateChanged — so without this
11+
* subscription the desktop app would not notice the merge until the next restart.
12+
*
13+
* @returns the unsubscribe function, or null when no listener was attached.
14+
*/
15+
const userDocListener = (uid) => {
16+
if (!uid) {
17+
return null;
18+
}
19+
20+
try {
21+
const db = getFirestore(firebaseApp);
22+
const userDocRef = doc(db, "users", uid);
23+
24+
return onSnapshot(
25+
userDocRef,
26+
(docSnapshot) => {
27+
const userData = docSnapshot.exists() ? docSnapshot.data() : null;
28+
submitAttrUtil(APP_CONSTANTS.GA_EVENTS.ATTR.BROWSERSTACK_ID, userData?.browserstackId ?? null);
29+
},
30+
(err) => {
31+
Logger.log(`[userDocListener] Encountered error: ${err}`);
32+
}
33+
);
34+
} catch (err) {
35+
Logger.log(`[userDocListener] Failed to attach listener: ${err}`);
36+
return null;
37+
}
38+
};
39+
40+
export default userDocListener;

app/src/store/selectors.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ export const getUserAttributes = (state) => {
215215
return getGlobalState(state)["userAttributes"];
216216
};
217217

218+
// Returns a primitive on purpose — consuming the whole `userAttributes` object in a component
219+
// re-renders on every attribute write, as the getUserRulesCount note below records.
220+
export const getUserBrowserstackId = (state) => {
221+
return getUserAttributes(state)?.browserstack_id;
222+
};
223+
218224
// Had to make a separate selector, since consuming
219225
// "userAttributes" directly in <RulesListContainer/> component goes into infinite re-renders
220226
// TODO: fix above

0 commit comments

Comments
 (0)