CP-14671: present perps enable-trading checklist as a formsheet modal route - #3995
CP-14671: present perps enable-trading checklist as a formsheet modal route#3995bogdandobritoiu wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Converts the perps “Set up trading account” checklist from an inline per-screen React Native Modal to an Expo Router modal route (stacked K2 formsheet), so it can be presented consistently above other perps modals and dismissed via native sheet interactions.
Changes:
- Added a new
(modals)/perpetualsEnableTradingmodal route and registered it undersecondaryModalScreensOptions. - Reworked the enable-trading UI into
PerpetualsEnableTradingScreenusingScrollScreen isModal, and updated the enable-trading gate hook to navigate to the route instead of rendering a local modal node. - Polished
k2-alpineChecklistrow visuals (text variant, completion icon, and loading state behavior) to match the design.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/k2-alpine/src/components/Checklist/Checklist.tsx | Updates checklist row typography and trailing affordance (check icon / loading indicator / action button). |
| packages/core-mobile/app/new/routes/(signedIn)/(modals)/perpetualsEnableTrading/index.tsx | Exposes the new enable-trading modal route screen as the route default export. |
| packages/core-mobile/app/new/routes/(signedIn)/(modals)/perpetualsEnableTrading/_layout.tsx | Adds a modal stack layout wrapper for the new route, consistent with other perps modal routes. |
| packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx | Registers the new modal route with secondaryModalScreensOptions so it presents above other perps modals. |
| packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsPlaceOrderScreen.tsx | Removes the in-tree modal render-prop and relies on route-based gating instead. |
| packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsPlaceOrderScreen.test.tsx | Updates mocks to match the simplified gate hook API (no enableTradingModal). |
| packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsManageScreen.tsx | Removes the in-tree modal render-prop and relies on route-based gating instead. |
| packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsEnableTradingScreen.tsx | Replaces the old RN Modal implementation with a formsheet-style modal route screen and adds auto-dismiss behavior. |
| packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsCloseScreen.tsx | Removes the in-tree modal render-prop and relies on route-based gating instead. |
| packages/core-mobile/app/new/features/trade/perpetuals/hooks/usePerpsEnableTradingGate.ts | Switches the gate from local modal state to router.navigate('/perpetualsEnableTrading'). |
Comments suppressed due to low confidence (2)
packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsEnableTradingScreen.tsx:43
dismiss()setsdismissedRef.current = trueeven whenrouter.canGoBack()is false, which can leave the sheet stuck open (and future dismiss attempts become no-ops). Only mark dismissed once you actually dismiss, and preferrouter.dismiss()for modal routes when possible.
packages/core-mobile/app/new/features/trade/perpetuals/hooks/usePerpsEnableTradingGate.ts:53- The enable-trading gate now navigates to a modal route when trading isn’t enabled, but there’s no unit test covering the new navigation behavior (e.g., that
router.navigate('/perpetualsEnableTrading')is invoked and the handler bails out). Adding a focused hook test (or updating an existing perps screen test) would help prevent regressions that could block order submission.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Coverage report ✅2/2 packages passed thresholds 🟢 @avalabs/core-mobile
🟢 @avalabs/k2-alpine
Artifacts and threshold sources
Source run: Mobile PR |
B0Y3R-AVA
left a comment
There was a problem hiding this comment.
Ran an adversarial pass on this (Claude + Codex cross-check). The conversion itself is clean: the rename left no dead refs, all four gate call sites still treat requireTradingEnabled as a sync boolean, and nothing else consumes the k2 Checklist so that change is contained.
Found two real bugs though, both the same shape: the modal used to be local state, now it's global navigation, so async work that finishes after the user has already left can still poke the nav stack. Details inline.
One more thing worth eyeballing when you do the device recordings: this is the first secondary sheet we present on top of another secondary sheet (perpetualsClose). Every other secondaryModalScreensOptions screen opens from a primary modal. Probably fine, but nobody has seen the stacked detents/gestures on a real device yet.
| return | ||
| } | ||
| dismissedRef.current = true | ||
| router.canGoBack() && router.back() |
There was a problem hiding this comment.
dismissedRef only gets set by our own dismiss() calls. Nothing flips it when the user swipe-dismisses the sheet natively.
So: tap "Enable unified account", swipe the sheet down while the tx is in flight. When the promise resolves, dismiss() still runs and router.back() fires against a stack where this sheet is already gone, popping the place-order/close/manage screen underneath it. The old code just did setVisible(false), harmless after unmount, so this is new with the route conversion.
Android makes it worse: a surplus back() can close the app (there's a comment about exactly that in (signedIn)/_layout.tsx), and navigationGuard patches push/navigate/replace but not back().
I'd set dismissedRef from a beforeRemove listener, or check navigation.isFocused() before calling back(). Dismissing by route identity instead of raw back() would also do it.
There was a problem hiding this comment.
Fixed in 89547fd. A beforeRemove listener now marks the sheet dismissed on any removal (same pattern as PerpetualsOnboardingScreen), so a continuation resolving after a native swipe-dismiss no-ops instead of firing back() — or, since the slide-to-order change, replace()ing the underlying screen into place-order, which made this worse than the original report.
| const { submitOrder } = usePerpsOrderSubmit() | ||
| const { requireTradingEnabled, enableTradingModal } = | ||
| usePerpsEnableTradingGate() | ||
| const { requireTradingEnabled } = usePerpsEnableTradingGate() |
There was a problem hiding this comment.
Same shape of bug on the caller side (the code is unchanged in this PR but the gate's behavior changed under it). In handleConfirm, the geo re-check awaits before the gate, and the gate now navigates instead of setting local state. If the user swipe-dismisses the place-order sheet while the re-check is in flight (it can look hung), the continuation still runs and router.navigate('/perpetualsEnableTrading') opens the setup sheet over whatever screen they're on now, after they already bailed. Previously setVisible(true) on the unmounted screen was a no-op.
A mounted/focused check after the await covers it.
There was a problem hiding this comment.
Fixed in 89547fd, in the gate hook rather than this call site: requireTradingEnabled() now checks navigation.isFocused() before presenting the sheet (it still returns false, so the submit aborts either way). Hook-level covers all four call sites — place-order is the only one with an await before the gate today, but close/manage won't regress if one grows an async precheck.
| /> | ||
| ) | ||
| ) : loading ? ( | ||
| <ActivityIndicator size="small" /> |
There was a problem hiding this comment.
Doc nits while you're in here: the loading docstring on ChecklistItem still says the spinner shows "in place of the action label" (it now replaces the whole button), and the done docstring still names Icons.Action.CheckCircle as the default.
There was a problem hiding this comment.
Fixed in 89547fd — done now names Icons.Navigation.Check and loading says the spinner replaces the button.
…tinue into place order For a first-time user (funded, one-time setup incomplete) the market details Short/Long slide now presents the enable-trading formsheet instead of place order, carrying the selected side; when the last setup step completes the sheet replaces itself with place order for that side. While setup status is still loading the slide goes straight to place order so set-up users are never detoured; the submit-time gate stays as the deep-link backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsEnableTradingScreen.tsx:53
dismissedRef.currentis set totruebefore any navigation occurs. Ifcoin/sidearen’t present androuter.canGoBack()isfalse,dismiss()won’t actually dismiss the sheet but will permanently no-op on subsequent calls (including the completion effect). Consider only flipping the guard once a navigation action is taken.
…creens Review fixes (PR #3995): the sheet was local state before the route conversion, so async continuations finishing after the user left were harmless; as navigation they poke the stack. Mark the sheet dismissed via a beforeRemove listener so a swipe-dismiss mid-step can't fire a stray back()/replace(), and have requireTradingEnabled skip presenting the sheet when its caller is no longer focused (place-order awaits a geo re-check before gating). Also refresh stale Checklist docstrings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ailed dismisses Review follow-ups (PR #3995): add a focused hook test for usePerpsEnableTradingGate (navigation on incomplete setup, focus bail-out, builder-fee-undefined semantics, status-loading composition), and only latch the sheet's dismiss guard when a navigation actually fires so a failed dismiss stays retryable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # packages/core-mobile/app/new/features/trade/perpetuals/screens/PerpetualsPlaceOrderScreen.tsx
Description
Ticket: CP-14671
The perps "Set up trading account" checklist (agent approval, builder-fee approval, unified account) shipped in #3984 as an inline half-height RN
Modalrendered inside each screen. The design (Figma) specifies the standard stacked K2 formsheet instead. This PR converts it to a proper modal route:/perpetualsEnableTradingroute registered withsecondaryModalScreensOptions(stacked-sheet detents), since it always presents on top of the place-order/close/manage modals.PerpetualsEnableTradingScreenbuilt onScrollScreen isModalwith title/subtitle per design; ports the three-step checklist logic unchanged. Dismissal is the sheet's native swipe/grabber (the design drops the old "Not now" button) plus a once-guardedrouter.back()when setup completes or is already satisfied.usePerpsEnableTradingGateis now a plain.tshook:requireTradingEnabled()navigates to the route instead of toggling a locally rendered modal; theenableTradingModalrender-prop is removed from PlaceOrder/Close/Manage screens andPerpsEnableTradingModalis deleted.Checklistpolish to match design: plain check icon on completed rows,ActivityIndicatorreplaces the action button while a step is in flight,subtitle2description.router.replaces into place-order with the originally selected Short/Long (so back returns to the details screen, not the sheet). While setup status is still resolving the slide goes straight to place-order so a set-up user is never detoured on cold load (usePerpsEnableTradingGatenow exposesisTradingStatusLoading;PerpsProvidernow surfacesisLoadingAgent); the submit-time gate remains as the backstop for deep links and cold-load edge cases.No new dependencies; no breaking changes.
Screenshots/Videos
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-07-22.at.12.04.19.mov
Testing
Dev Testing
iOS - 9382
Android - 9383
Happy path:
Edge cases:
QA Testing
Checklist
Please check all that apply (if applicable)
🤖 Generated with Claude Code