fix(predict): prevent buy/sell sheet from being cropped in HomepageDiscoveryTabs#30219
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
| */ | ||
| const clearErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const dismissedWithErrorRef = useRef(false); | ||
|
|
There was a problem hiding this comment.
Navigate via ROOT so the screen resolves correctly
…mcu-752-predict-sheet-homepage-discovery-tabs
MarioAslau
left a comment
There was a problem hiding this comment.
High-priority issues
H1. Multi-provider race on _providerInSheetMode / _providerMounted globals
PredictPreviewSheetProvider is mounted in two places:
app/components/UI/Predict/routes/index.tsx(PredictScreenStack, default → sheet mode)app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx(nowdisableBottomSheet)
When the user is on the Predictions tab inside HomepageDiscoveryTabs and taps a market, openBuySheet calls navigation.navigate(Routes.PREDICT.ROOT, { screen: BUY_PREVIEW, params }). This pushes onto the root navigator and mounts PredictScreenStack, which mounts a second PredictPreviewSheetProvider. For a window of time both providers are mounted, but the singleton booleans:
let _providerMounted = false;
/**
* Returns whether `PredictPreviewSheetProvider` is currently mounted somewhere
...
*/
export function isPredictSheetProviderMounted(): boolean {
return _providerMounted;
}…cannot represent two providers simultaneously. The mount/unmount effects race, so:
- After the inner provider unmounts (e.g., user pops
BUY_PREVIEW), it sets_providerMounted = falseand_providerInSheetMode = false, even though the outer (disableBottomSheet) provider is still mounted. - After the inner provider mounts, it sets
_providerInSheetMode = true, even though the outer one isdisableBottomSheet.
In today's code this happens to produce the correct toast behavior in the most common flow because the outer provider's retry-toast effect short-circuits on disableBottomSheet. But the design is fragile, any future consumer of these globals (or any reordering of useEffect runs in StrictMode/dev) can flip behavior silently. It's also genuinely incorrect: there is a Predict provider mounted, and during the interval the inner stack is mounted the outer one is implicitly "in sheet mode" from the global's perspective, even though it explicitly opted out.
Suggested fix: replace the two booleans with reference counters, e.g.:
let _providerMountedCount = 0;
let _providerSheetModeMountedCount = 0;
export function isPredictSheetProviderMounted() {
return _providerSheetModeMountedCount > 0;
}…incremented on mount and decremented on unmount based on disableBottomSheet. This makes the behavior independent of mount/unmount ordering when multiple providers coexist.
H2. Analytics behavior change in PredictBuyPreview screen mode is broader than the stated scope
The PR description says the beforeRemove listener exists for screen mode in the new disableBottomSheet flow. The implementation, however, registers the listener for every screen-mode mount of PredictBuyPreview:
useEffect(() => {
if (isSheetMode) return;
return addListener('beforeRemove', () => {
if (!predictBuyPreviewOrderInitiatedRef.current) {
const dismissalMethod = predictBuyPreviewDismissedViaBackRef.current
? PredictDismissalMethod.BACK_BUTTON
: PredictDismissalMethod.SWIPE;
Engine.context.PredictController.trackBetslipDismissed({ ... });
}
});
}, [addListener, isSheetMode, analyticsProperties, transactionActiveAbTests]);That includes the pre-existing path where bottomSheetEnabled === false (LD flag off) i.e., users who were already navigating to the full-screen buy preview before this PR. Previously those users only fired Predict Betslip Dismissed when they tapped the in-screen back button; swipe/hardware-back dismissals were silent. After this PR, those swipe/hardware-back dismissals will start firing SWIPE events.
This may well be desirable (it's arguably a correctness improvement and matches what usePredictBuyActions.ts already does for the AnyToken path), but the PR title and description frame it as a fix for the Hub Page Discovery Tabs A/B test only. Reviewers/analytics owners may be missing that the change ships analytics for all flagless users on this path. Two options:
- Gate the listener on
props.mode === 'screen' && /* something distinguishing the new flow */so the analytics change is scoped to the new flow, or - Keep the broader change but call it out explicitly in the changelog/Jira and confirm with the analytics team that the new event volume is expected.
Medium-priority issues
M1. isPredictSheetProviderMounted() no longer matches its name
The function is documented as "whether PredictPreviewSheetProvider is currently mounted somewhere in the tree", but after this PR it returns _providerMounted && _providerInSheetMode:
let _providerMounted = false;
let _providerInSheetMode = false;
export function isPredictSheetProviderMounted(): boolean {
return _providerMounted && _providerInSheetMode;
}A disableBottomSheet-mounted provider is still mounted, but the function now lies about that. The only caller (usePredictToastRegistrations) actually uses this boolean to mean "will the provider's retry toast cover this failure?", not "is the provider mounted?". Rename to reflect the new semantics — e.g. shouldSuppressLegacyOrderFailureToast() or isPredictSheetProviderInSheetMode() — and update the doc comment. This avoids the next caller misinterpreting the function and (per H1) re-introducing the silent-failure bug that motivated this fix.
M2. Misleading inline comment in the back-button handler
onPress={() => {
predictBuyPreviewDismissedViaBackRef.current = true;
if (isSheetMode) {
// Sheet dismissals are tracked in PredictPreviewSheetContext.onBuyDismiss.
Engine.context.PredictController.trackBetslipDismissed({
...
});
onClose?.();
} else {
// Screen mode: beforeRemove listener owns dismiss tracking.
goBack();
}
}}The first comment ("Sheet dismissals are tracked in PredictPreviewSheetContext.onBuyDismiss") sits directly above a block that does call trackBetslipDismissed. The intent is "swipe/hardware-back sheet dismissals are tracked elsewhere; this branch handles only the BACK_BUTTON sheet path", but as written it reads like a contradiction. Reword to something like:
// Sheet mode: this branch handles the explicit BACK_BUTTON dismissal.
// Swipe / hardware-back dismissals are handled by onBuyDismiss in PredictPreviewSheetContext.M3. lastBuyParamsRef.current = params is a dead store under disableBottomSheet
const openBuySheet = useCallback(
(params: PredictBuyPreviewParams) => {
lastBuyParamsRef.current = params; // ← always set
if (bottomSheetEnabled && !disableBottomSheet) {
...
} else {
navigation.navigate(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
params,
});
}
},
...,
);The retry-toast effect that consumes lastBuyParamsRef.current short-circuits on disableBottomSheet, so when disableBottomSheet === true we are storing params we will never read. Two minor concerns:
- We hold a long-lived strong reference to
params.market,outcome,outcomeToken, etc., for the lifetime of the provider, even though we'll never use them. - It muddies the retry contract: a future maintainer could be tempted to read
lastBuyParamsReffrom a code path that doesn't gate ondisableBottomSheetand re-introduce the same silent-failure bug.
Either gate the assignment on !disableBottomSheet, or comment the intentional dead-store.
M4. Missing test: feature-flag-off + disableBottomSheet interaction
The new tests cover disableBottomSheet=true while bottomSheetEnabled is true, and the existing tests cover bottomSheetEnabled=false with no disableBottomSheet. There's no test for bottomSheetEnabled=false with disableBottomSheet=true. In that combo, both branches force navigation, but _providerInSheetMode is now driven purely by disableBottomSheet (i.e. false), regardless of the runtime feature flag — which is what we want, but should be locked in with a test so a refactor of the flag selectors can't regress it.
M5. JSDoc on shared module-level refs is now stale
/**
* Module-level ref so PredictPreviewSheetContext can distinguish a programmatic
* back-button dismiss from a swipe/hardware-back dismiss when onBuyDismiss fires.
* Set to true in the back-button handler, reset to false by the sheet context
* after consuming it.
*/
export const predictBuyPreviewDismissedViaBackRef = { current: false };The doc now lies on three counts after this PR:
- The ref is also consumed by the new
beforeRemovelistener in screen mode (PredictBuyPreview.tsx), not just byonBuyDismiss. - It is also consumed by
usePredictBuyActions.tsfor the AnyToken screen-mode path. - It is now reset on mount by
PredictBuyPreviewitself (line 121), in addition to being reset byonBuyDismiss.
Update the JSDoc to reflect that this is shared module-level state with three consumers, and document the reset contract to reduce future foot-guns.
…-area padding - Replace _providerMounted/_providerInSheetMode booleans with a reference counter (_providerSheetModeCount) to avoid last-writer-wins race when PredictPreviewSheetProvider is mounted in both PredictScreenStack and HomepageDiscoveryTabs simultaneously - Rename isPredictSheetProviderMounted → shouldSuppressLegacyOrderFailureToast to reflect actual semantics (checks sheet-mode, not mount status) - Gate beforeRemove swipe-dismiss analytics behind trackSwipeDismiss route param so the change is scoped to the disableBottomSheet flow and does not change event volume for the pre-existing flagless screen-mode path - Move lastBuyParamsRef assignment inside the sheet-mode branch to eliminate dead store when disableBottomSheet is active - Fix extra bottom safe-area padding in PredictFeed and PerpsHomeView when rendered as tabs inside HomepageDiscoveryTabs (hideHeader=true → edges=[])
|
@MarioAslau I have addressed the issues you've found in this 3a47acf |
…mcu-752-predict-sheet-homepage-discovery-tabs
| return ( | ||
| <SafeAreaView | ||
| edges={{ bottom: 'additive' }} | ||
| edges={hideHeader ? [] : { bottom: 'additive' }} |
There was a problem hiding this comment.
This ternary is needed to solve the bottom padding issue
| Before | After |
|---|---|
![]() |
![]() |
| style={styles.container} | ||
| edges={hideHeader ? { bottom: 'additive' } : undefined} | ||
| > | ||
| <SafeAreaView style={styles.container} edges={hideHeader ? [] : undefined}> |
There was a problem hiding this comment.
This ternary is needed to solve the bottom padding issue
| Before | After |
|---|---|
![]() |
![]() |
MarioAslau
left a comment
There was a problem hiding this comment.
High-priority issues
H1. hadEnteredAmount analytics value is now inconsistent across dismissal paths
Five distinct dismissal paths now emit Predict Betslip Dismissed, but they read hadEnteredAmount from two different sources:
| Path | Source of hadEnteredAmount |
|---|---|
| Sheet-mode, back-button | currentValue > 0 (instantaneous) |
Sheet-mode, swipe / hardware-back (onBuyDismiss) |
predictBuyPreviewSessionRef.hadEnteredAmount (sticky) |
Screen-mode + trackSwipeDismiss, back-button (via beforeRemove) |
predictBuyPreviewSessionRef.hadEnteredAmount (sticky) |
Screen-mode + trackSwipeDismiss, swipe / hardware-back |
predictBuyPreviewSessionRef.hadEnteredAmount (sticky) |
| Screen-mode flagless, back-button | currentValue > 0 (instantaneous) |
The session ref is "sticky": once currentValue > 0 it flips to true for the rest of the session and is never set back to false (only reset on mount/unmount):
useEffect(() => {
if (currentValue > 0) {
predictBuyPreviewSessionRef.hadEnteredAmount = true;
}
...
}, [currentValue, isCalculating]);So a user who types $10, clears it back to $0, then taps back will report:
hadEnteredAmount = falsein sheet-mode back-button and screen-mode flagless back-buttonhadEnteredAmount = truein the new screen-mode-trackSwipeDismissback-button (which defers tobeforeRemove)
The same physical action (tap back-button) reports different analytics depending on which presentation mode the user happens to be in. This will skew the had_entered_amount distribution for the HomepageDiscoveryTabs cohort relative to the rest of Predict and contaminate the A/B test signal the PR is meant to support.
Suggested fix: pick one semantic and apply it everywhere. The sticky semantic is arguably the more useful one ("did the user ever engage?"), but whichever is chosen, all five paths should agree. At minimum, the new beforeRemove listener and the back-button handler should read from the same source when the user is in screen mode.
H2. beforeRemove listener fires on every pop, not just dismissals
useEffect(() => {
if (isSheetMode || !trackSwipeDismiss) return;
return addListener('beforeRemove', () => {
if (!predictBuyPreviewOrderInitiatedRef.current) {
const dismissalMethod = predictBuyPreviewDismissedViaBackRef.current
? PredictDismissalMethod.BACK_BUTTON
: PredictDismissalMethod.SWIPE;
Engine.context.PredictController.trackBetslipDismissed({ ... });
}
});
}, [addListener, isSheetMode, trackSwipeDismiss, ...]);beforeRemove fires for any removal of the screen from the navigation stack, including programmatic StackActions.pop() triggered by a successful order:
useEffect(() => {
if (result?.success) {
if (isSheetMode) {
onClose?.();
} else {
dispatch(StackActions.pop()); // ← will trigger beforeRemove
}
}
}, [dispatch, result, isSheetMode, onClose]);The !predictBuyPreviewOrderInitiatedRef.current gate inside the listener is what's supposed to prevent the false-positive dismissal event in this flow. That works as long as onPlaceBet always runs first:
const onPlaceBet = useCallback(async () => {
if (!preview || isBelowMinimum || isInsufficientBalance) return;
predictBuyPreviewOrderInitiatedRef.current = true; // ← gate setter
await placeOrder({ analyticsProperties, preview });
}, ...);But the gate is fragile in a few ways:
- The ref is module-level and shared with
PredictBuyWithAnyToken/usePredictBuyActions. If a previousPredictBuyWithAnyTokenmount left the reftrueand aPredictBuyPreviewmounts in screen-mode-trackSwipeDismissimmediately after, thebeforeRemovelistener will silently swallow the first dismissal until the mount-time reset on line 125 lands. The mount-time reset does run, so the dominant ordering is safe — but the cross-component coupling is hard to verify by reading the code. - There's no test that asserts "successful order does not emit a SWIPE dismissal event". The test that comes closest only checks the negative case for
predictBuyPreviewOrderInitiatedRef.current = true(line 2599) by manually toggling the ref. A higher-fidelity test would render the component, driveplaceOrderto success, and asserttrackBetslipDismissedwas not called.
Suggested fix: add the integration-level test described above. Optionally, replace predictBuyPreviewOrderInitiatedRef with a stronger signal (e.g. checking result?.success or the e.data?.action.type === 'POP' style guard inside the listener).
H3. Screen-mode flagless BACK_BUTTON tracking is now gated on orderInitiatedRef — quietly different from previous behavior
Before this PR, the screen-mode back-button handler fired BACK_BUTTON unconditionally:
- // (previous main):
- predictBuyPreviewDismissedViaBackRef.current = true;
- Engine.context.PredictController.trackBetslipDismissed({
- ...,
- dismissalMethod: PredictDismissalMethod.BACK_BUTTON,
- });
- if (isSheetMode) onClose?.();
- else goBack();After 8e24485, the flagless path now gates the event on !predictBuyPreviewOrderInitiatedRef.current:
} else {
// Screen mode (flagless path): beforeRemove listener is not
// registered, so track back-button dismissal directly here.
if (!predictBuyPreviewOrderInitiatedRef.current) {
Engine.context.PredictController.trackBetslipDismissed({
analyticsProperties,
dismissalMethod: PredictDismissalMethod.BACK_BUTTON,
hadEnteredAmount: currentValue > 0,
timeOnScreenMs: Date.now() - mountTimestampRef.current,
activeAbTests: transactionActiveAbTests,
});
}
goBack();
}This is a behavior change for all existing users of the flagless screen-mode path (i.e. users who already see the full-screen preview because selectPredictBottomSheetEnabledFlag is false):
- Before: tap Place Bet → bet in-flight → tap back-button →
BACK_BUTTONevent fires. - After: same sequence → event suppressed.
It's plausible that this is the desired semantic (the user committed; calling that a "dismissal" is dubious), but:
- The sheet-mode back-button handler (line 381) was not updated symmetrically — it still fires
BACK_BUTTONunconditionally even whenorderInitiatedRef === true. So we now have:- Sheet-mode back-button after
Place Bet: firesBACK_BUTTONevent - Screen-mode back-button after
Place Bet: silently swallows the event
- Sheet-mode back-button after
- The PR description doesn't call out this analytics change for the flagless cohort.
- There is no test for this branch — the new
describe('beforeRemove dismiss tracking (screen mode)', ...)block (line 2549) covers thetrackSwipeDismiss=truecases, but not the new gated flagless back-button path.
Suggested fix: either revert the gate on the flagless back-button (keep prior unconditional firing), or apply the same gate to the sheet-mode back-button so all three back-button paths are consistent. Either way, add a test that locks in the chosen semantic for the flagless path, and mention the change in the PR body so the analytics team isn't surprised.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 25b2bb1. Configure here.
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection: Changes Overview:
Tag Selection Rationale:
Performance Test Selection: |
|








Description
When
PredictFeedis rendered insideHomepageDiscoveryTabs(behind thecoreMCU589AbtestHubPageDiscoveryTabsA/B test), thePredictPreviewSheetProviderbottom sheet is cropped by the tab layout and unusable.Changes:
disableBottomSheetprop toPredictPreviewSheetProvider— when true,openBuySheet/openSellSheetnavigate to the full-screen bet slip viaRoutes.PREDICT.ROOTinstead of opening the sheet;HomepageDiscoveryTabspasses this prop for the Predictions tab_providerMountedboolean singleton with a reference counter (_providerSheetModeCount) to correctly handle concurrent providers (e.g.PredictScreenStack+HomepageDiscoveryTabsboth mounted); renamed the exported helper toshouldSuppressLegacyOrderFailureToastto reflect actual semantics — adisableBottomSheetprovider must not suppress the failure toastbeforeRemovelistener toPredictBuyPreviewfor swipe/hardware-back dismiss tracking in screen mode, gated behind atrackSwipeDismissroute param set only bydisableBottomSheetnavigations — preserves prior behavior for the pre-existing flagless pathPredictFeedandPerpsHomeViewwhen rendered as tabs (hideHeader=true→edges=[])Changelog
CHANGELOG entry:null
Related issues
Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-752 https://consensyssoftware.atlassian.net/browse/TMCU-766
Manual testing steps
Screenshots/Recordings
Routing Fix
Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-05-19.at.16.58.07.mov
Before
broken.mov
After
Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-05-19.at.16.58.07.mov
Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Changes Predict buy/sell routing and dismissal analytics/Toast suppression behavior based on new
disableBottomSheetmode, which could affect navigation flows and user-facing toasts if misconfigured. Updates are well-tested but touch cross-cutting UI/analytics logic used in multiple entry points.Overview
Fixes Predict bet slip being cropped inside
HomepageDiscoveryTabsby addingdisableBottomSheettoPredictPreviewSheetProviderso buy/sell actions navigate to full-screen previews (viaRoutes.PREDICT.ROOT) instead of opening a bottom sheet.Refines order-failure toast suppression by replacing a singleton “provider mounted” boolean with a sheet-mode provider refcount and renaming the helper to
shouldSuppressLegacyOrderFailureToast, ensuring providers mounted withdisableBottomSheetdo not suppress the legacy failure toast.Adds screen-mode swipe/back dismissal tracking for
PredictBuyPreviewbehind a newtrackSwipeDismissroute param (only set bydisableBottomSheetnavigations) to avoid double-counting and keep analytics changes scoped to the discovery-tabs flow.Also adjusts SafeAreaView
edgesinPredictFeedandPerpsHomeViewwhenhideHeaderis true to avoid extra bottom padding in tab layouts.Reviewed by Cursor Bugbot for commit 9565740. Bugbot is set up for automated code reviews on this repo. Configure here.