fix(demo-wallet): loading skeletons, error states, offset pagination, empty-state stubs#486
fix(demo-wallet): loading skeletons, error states, offset pagination, empty-state stubs#486lesyuk wants to merge 2 commits into
Conversation
… empty-state stubs
Sections (History, Assets, NFTs) previously shimmered forever on a failed list
fetch and could flash a false "empty" while still loading. Add explicit
loading / error / empty handling backed by real load state.
Store (wallet-core):
- Add isLoadingEvents / eventsLoaded / eventsError / accountStatus to
walletManagement, driven from loadEvents.
- Coalesce concurrent loadEvents calls for the same (limit, offset) onto one
in-flight request (removes the duplicate concurrent /traces requests that the
StrictMode double-invoke amplified); a different window (Load more) chains
after and is never dropped.
- Best-effort single retry on a transient toncenter timeout.
- Fetch account status (addressInformation) so the UI can distinguish a
fresh/uninitialized account (truly no transactions) from a still-loading or
failed fetch.
- Reset the new flags + in-flight guard on wallet switch / remove / clear.
UI (demo-wallet):
- Add a reusable animated Skeleton primitive (gray shimmer within the app
palette, respects prefers-reduced-motion) with layout-matched variants for the
Asset row, NFT tile and History row.
- Show shimmers while loading on both the dashboard preview cards and the full
Assets / NFTs / History pages.
- Empty-state stubs ("No NFTs yet" / "No transactions yet") show only after a
successful load returned nothing (or a confirmed fresh account) — never while
loading or after a failed fetch. On the dashboard a genuinely-empty wallet
shows the stub instead of the section being hidden.
- Error states: the dashboard preview hides the section on a load error; the
full page shows a short "Unable to load ..." line instead of an endless shimmer.
- History "Load more" pages by offset -- (25,0) -> (25,25) -> (25,50) --
appending each page to the accumulated list (deduped by eventId) instead of
refetching a growing limit; shows a spinner while the next page loads.
Work around a server-side toncenter limitation where
GET /api/v3/traces?limit=N returns 500 ("timeout: context deadline exceeded")
for small N (observed 1..10) but succeeds for 25: default the events batch to 25
everywhere. Because the dashboard preview and History now request the same first
page (limit 25, offset 0), the store coalesces them into one /traces request.
Assets: surface the mainnet base tokens (native Gram + USDT + XAUt) at a zero
balance when not held, on BOTH the dashboard preview and the full Assets page,
via the shared useAssetRows hook so both render the exact same list (the preview
shows its first N); held jettons are merged in and win on any address collision.
Mainnet-only; testnet and held-token display unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 46 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds a reusable Skeleton UI component and shimmer CSS, applying it to asset, NFT, and transaction row placeholders with explicit loading/empty/error state branching. Extends useAssetRows with default mainnet jettons. Reworks wallet-core's transaction events loading with pagination, request coalescing, account-status tracking, and richer hook state. ChangesLoading Skeleton UI Components
Estimated code review effort: 4 (Complex) | ~60 minutes Transaction Events Pagination and State
Estimated code review effort: 4 (Complex) | ~55 minutes Sequence Diagram(s)sequenceDiagram
participant HistoryScreen
participant useTransactionRows
participant walletManagementSlice
participant apiClient
HistoryScreen->>useTransactionRows: mount / pageSize change
useTransactionRows->>walletManagementSlice: loadEvents(pageSize, 0)
walletManagementSlice->>apiClient: getEvents(limit, offset)
apiClient-->>walletManagementSlice: events or timeout
walletManagementSlice-->>useTransactionRows: rows, isLoadingEvents, eventsError
useTransactionRows-->>HistoryScreen: rows, hasMore, isError, showEmpty, loadMore
HistoryScreen-->>HistoryScreen: render rows, error, empty, or skeleton
HistoryScreen->>useTransactionRows: loadMore() on click
useTransactionRows->>walletManagementSlice: loadEvents(pageSize, events.length)
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
prettier/prettier fixes flagged by CI lint (formatting only, no behavior change): transaction-history skeleton map and use-transaction-rows early-return.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx (1)
44-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider announcing the loading state to assistive tech.
Skeletonrendersaria-hiddenplaceholders, so screen-reader users get no indication the grid is loading. Arole="status"/visually-hidden "Loading NFTs…" text on this branch would close that gap.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx` around lines 44 - 50, The loading branch in NftScreen currently only renders aria-hidden skeleton tiles, so assistive tech gets no loading cue. Update the conditional fallback in nft-screen.tsx to announce the loading state by adding a status region (for example, a role="status" wrapper with visually hidden “Loading NFTs…” text) alongside the existing NftTileSkeleton grid so screen-reader users are informed when NFTs are loading.apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx (1)
26-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate
isErrorlogic — hoist intouseAssetRows.This exact expression is duplicated in
dashboard-assets.tsx(Line 38). SinceuseAssetRowsalready exists as the single source of truth shared by both surfaces, computingisErrorthere once (alongsideassetsReady) would prevent the two call sites from silently diverging if the rule changes.♻️ Suggested consolidation
interface AssetRows { tonRow: AssetRowData | null; jettonRows: AssetRowData[]; assetsReady: boolean; + isError: boolean; } export const useAssetRows = (): AssetRows => { - const { userJettons, lastJettonsUpdate } = useJettons(); + const { userJettons, lastJettonsUpdate, isLoadingJettons, error } = useJettons(); ... + const isError = lastJettonsUpdate === 0 && userJettons.length === 0 && error !== null && !isLoadingJettons; ... - return { tonRow, jettonRows, assetsReady }; + return { tonRow, jettonRows, assetsReady, isError }; };Then both
AssetsScreenandDashboardAssetsconsumeisErrorfrom the hook instead of recomputing it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx` around lines 26 - 31, Hoist the duplicated `isError` computation out of `AssetsScreen` and into `useAssetRows`, since that hook is already the shared source of truth for asset state. Update `useAssetRows` to derive and return `isError` alongside `assetsReady`, using the same jettons/error conditions currently computed in `assets-screen.tsx`, then change both `AssetsScreen` and `DashboardAssets` to consume `isError` from the hook instead of recalculating it so the rule stays consistent in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx`:
- Around line 23-30: Extract the duplicated NFT view-state derivation into a
shared helper so `nft-screen.tsx` and `nfts-card.tsx` do not drift apart. Move
the `hasNfts`, `showEmpty`, and `isError` logic currently computed from
`useNfts()` into a reusable hook or nearby utility such as `useNftViewState`,
and have both components consume that shared result instead of inlining the same
conditions and comments. Keep the behavior unchanged while centralizing the
loading/empty/error semantics in one place.
In
`@apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx`:
- Around line 28-52: The first-page error state in HistoryScreen has no recovery
path, since loadMore is blocked by hasMore and the empty/error branch only
renders text. Update useTransactionRows to expose a retry or reload action for
the initial fetch, and wire that into the isError branch of HistoryScreen as a
button. Keep the existing loadMore behavior for pagination, but ensure the new
retry path can re-run the zero-offset fetch even when hasMore is false.
In
`@apps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsx`:
- Around line 57-59: Prettier is failing on the skeleton row rendering in
transaction-history because the Array.from map body is wrapped in unnecessary
parentheses. Update the TransactionHistory component’s SKELETON_ROWS mapping so
the TransactionRowSkeleton JSX is inlined directly in the map callback body,
matching the formatter’s preferred style and removing the wrapping parentheses.
In `@apps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.ts`:
- Around line 142-145: The showEmpty boolean expression in useTransactionRows
needs to be reformatted to satisfy Prettier and unblock the unit check. Update
the layout of the conditional in use-transaction-rows.ts so the expression is
wrapped consistently with the formatter’s style, keeping the logic in showEmpty
unchanged and ensuring the file matches the output of the project formatter.
In `@demo/wallet-core/src/store/slices/walletManagementSlice.ts`:
- Around line 868-905: The events update block in the walletManagementSlice
loadEvents flow is missing the wallet-address guard, so a stale request can
write previous-wallet data after switchWallet. Add the same
s.walletManagement.address === address check used for accountStatus around the
set(...) logic that mutates events, confirmedTraceIds, confirmedExternalHashes,
pendingTransactions, and eventsError, and only apply the response when the
active wallet still matches the request address.
---
Nitpick comments:
In
`@apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx`:
- Around line 26-31: Hoist the duplicated `isError` computation out of
`AssetsScreen` and into `useAssetRows`, since that hook is already the shared
source of truth for asset state. Update `useAssetRows` to derive and return
`isError` alongside `assetsReady`, using the same jettons/error conditions
currently computed in `assets-screen.tsx`, then change both `AssetsScreen` and
`DashboardAssets` to consume `isError` from the hook instead of recalculating it
so the rule stays consistent in one place.
In `@apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx`:
- Around line 44-50: The loading branch in NftScreen currently only renders
aria-hidden skeleton tiles, so assistive tech gets no loading cue. Update the
conditional fallback in nft-screen.tsx to announce the loading state by adding a
status region (for example, a role="status" wrapper with visually hidden
“Loading NFTs…” text) alongside the existing NftTileSkeleton grid so
screen-reader users are informed when NFTs are loading.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2a739034-1770-41aa-9d7f-73001d018b43
📒 Files selected for processing (16)
apps/demo-wallet/src/App.cssapps/demo-wallet/src/core/components/ui/skeleton/index.tsapps/demo-wallet/src/core/components/ui/skeleton/skeleton.tsxapps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsxapps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsxapps/demo-wallet/src/features/assets/hooks/use-asset-rows.tsapps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsxapps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsxapps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsxapps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsxapps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsxapps/demo-wallet/src/features/transactions/components/transaction-history/transaction-history.tsxapps/demo-wallet/src/features/transactions/components/transaction-row/transaction-row.tsxapps/demo-wallet/src/features/transactions/hooks/use-transaction-rows.tsdemo/wallet-core/src/store/slices/walletManagementSlice.tsdemo/wallet-core/src/types/store.ts
| const { userNfts, formatNftIndex, isLoadingNfts, lastNftsUpdate, error } = useNfts(); | ||
|
|
||
| const hasNfts = userNfts.length > 0; | ||
| // "No NFTs yet" is truthful once a load has succeeded and returned nothing (even if a later | ||
| // background refresh then errored). "Couldn't load" is only when no load ever succeeded and | ||
| // the fetch failed — otherwise the first load is still running (shimmer). | ||
| const showEmpty = !hasNfts && lastNftsUpdate > 0; | ||
| const isError = !hasNfts && lastNftsUpdate === 0 && error !== null && !isLoadingNfts; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Duplicate state-derivation logic — extract to a shared hook/utility.
The hasNfts/showEmpty/isError derivation (with identical inline comments) is copy-pasted verbatim in nfts-card.tsx. Extracting this into a shared hook (e.g. useNftViewState(useNfts())) would prevent the two call sites from silently diverging as the loading semantics evolve.
As per coding guidelines, "Extract utility functions to a nearby utils file and types to a types file when it helps."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx`
around lines 23 - 30, Extract the duplicated NFT view-state derivation into a
shared helper so `nft-screen.tsx` and `nfts-card.tsx` do not drift apart. Move
the `hasNfts`, `showEmpty`, and `isError` logic currently computed from
`useNfts()` into a reusable hook or nearby utility such as `useNftViewState`,
and have both components consume that shared result instead of inlining the same
conditions and comments. Keep the behavior unchanged while centralizing the
loading/empty/error semantics in one place.
Source: Coding guidelines
| const { rows, hasMore, isLoading, showEmpty, isError, loadMore } = useTransactionRows(); | ||
|
|
||
| // With rows already on screen, `isLoading` means the next offset page is being fetched. | ||
| const isLoadingMore = isLoading && rows.length > 0; | ||
|
|
||
| return ( | ||
| <NewLayout header={<ScreenHeader title="History" onBack={() => navigate('/wallet')} />}> | ||
| {rows.length === 0 ? ( | ||
| <p className="py-12 text-center text-sm text-gray-400">No transactions yet</p> | ||
| ) : ( | ||
| {rows.length > 0 ? ( | ||
| <div className="space-y-1"> | ||
| {rows.map((row) => ( | ||
| <TransactionRow key={row.id} {...row} /> | ||
| ))} | ||
| </div> | ||
| ) : isError ? ( | ||
| <p className="py-12 text-center text-sm text-gray-400">Unable to load transactions</p> | ||
| ) : showEmpty ? ( | ||
| <p className="py-12 text-center text-sm text-gray-400">No transactions yet</p> | ||
| ) : ( | ||
| // Loading first page: show a shimmer, never a false "empty". | ||
| <div className="space-y-1"> | ||
| {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( | ||
| <TransactionRowSkeleton key={index} /> | ||
| ))} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
No retry path when the first-page fetch fails.
When isError is true, the screen shows "Unable to load transactions" with no way to recover short of leaving and re-entering the page. loadMore can't be reused for this: its guard requires hasMore, which is still false (the initial default) since a failed first-page fetch never reaches the hasNextEvents assignment in loadEvents. The "Load more" button itself is also hidden here since it's gated by rows.length > 0 (line 54), which is false in the error state.
Consider exposing a retry (or reusing a zero-offset reload) from useTransactionRows and wiring a "Retry" button into the isError branch.
💡 Suggested addition (sketch)
) : isError ? (
- <p className="py-12 text-center text-sm text-gray-400">Unable to load transactions</p>
+ <div className="py-12 text-center">
+ <p className="text-sm text-gray-400">Unable to load transactions</p>
+ <Button variant="secondary" size="sm" className="mt-3" onClick={retry}>
+ Retry
+ </Button>
+ </div>
) : showEmpty ? (🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/demo-wallet/src/features/transactions/components/history-screen/history-screen.tsx`
around lines 28 - 52, The first-page error state in HistoryScreen has no
recovery path, since loadMore is blocked by hasMore and the empty/error branch
only renders text. Update useTransactionRows to expose a retry or reload action
for the initial fetch, and wire that into the isError branch of HistoryScreen as
a button. Keep the existing loadMore behavior for pagination, but ensure the new
retry path can re-run the zero-offset fetch even when hasMore is false.
| set((s) => { | ||
| // offset 0 = fresh first page (replace). offset > 0 = "Load more" page: | ||
| // append to what's already loaded and dedupe by eventId so a page overlap | ||
| // (e.g. a new tx shifting the window) can't produce duplicate rows. | ||
| if (offset > 0) { | ||
| const existing = s.walletManagement.events as Array<{ eventId?: string }>; | ||
| const seenEventIds = new Set<string>( | ||
| existing.map((ev) => ev.eventId).filter((id): id is string => !!id), | ||
| ); | ||
| const appended = (response.events as Array<{ eventId?: string }>).filter( | ||
| (ev) => !ev.eventId || !seenEventIds.has(ev.eventId), | ||
| ); | ||
| s.walletManagement.events = [...existing, ...appended]; | ||
| } else { | ||
| s.walletManagement.events = response.events; | ||
| } | ||
| s.walletManagement.hasNextEvents = response.hasNext; | ||
| const eventTraceIds = new Set<string>(); | ||
| const eventExtHashes = new Set<string>(); | ||
| for (const ev of response.events as Array<{ eventId?: string; traceExternalHash?: string }>) { | ||
| if (ev.eventId) eventTraceIds.add(ev.eventId); | ||
| if (ev.traceExternalHash) eventExtHashes.add(Base64ToHex(ev.traceExternalHash)); | ||
| } | ||
| state.walletManagement.confirmedTraceIds = [ | ||
| ...state.walletManagement.confirmedTraceIds, | ||
| s.walletManagement.confirmedTraceIds = [ | ||
| ...s.walletManagement.confirmedTraceIds, | ||
| ...eventTraceIds, | ||
| ].slice(-50); | ||
| state.walletManagement.confirmedExternalHashes = [ | ||
| ...state.walletManagement.confirmedExternalHashes, | ||
| s.walletManagement.confirmedExternalHashes = [ | ||
| ...s.walletManagement.confirmedExternalHashes, | ||
| ...eventExtHashes, | ||
| ].slice(-50); | ||
| state.walletManagement.pendingTransactions = state.walletManagement.pendingTransactions.filter( | ||
| s.walletManagement.pendingTransactions = s.walletManagement.pendingTransactions.filter( | ||
| (p) => | ||
| !(p.traceId && eventTraceIds.has(p.traceId)) && | ||
| !(p.externalHash && eventExtHashes.has(p.externalHash)), | ||
| ); | ||
| s.walletManagement.eventsError = false; | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the target file and inspect the relevant region
ast-grep outline demo/wallet-core/src/store/slices/walletManagementSlice.ts --view expanded || true
wc -l demo/wallet-core/src/store/slices/walletManagementSlice.ts
sed -n '800,940p' demo/wallet-core/src/store/slices/walletManagementSlice.ts
# Find all address guards and wallet-switch related logic in the slice
rg -n "address !== address|inFlightEventsLoad|switchWallet|loadEvents|getEvents|confirmedTraceIds|confirmedExternalHashes|pendingTransactions" demo/wallet-core/src/store/slices/walletManagementSlice.tsRepository: ton-org/kit
Length of output: 10825
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '295,385p' demo/wallet-core/src/store/slices/walletManagementSlice.ts
sed -n '520,590p' demo/wallet-core/src/store/slices/walletManagementSlice.ts
sed -n '680,730p' demo/wallet-core/src/store/slices/walletManagementSlice.tsRepository: ton-org/kit
Length of output: 9605
Guard the events write against wallet switches. switchWallet clears inFlightEventsLoad, but an already-running loadEvents can still resolve after the new wallet is active and repopulate events, confirmedTraceIds, confirmedExternalHashes, and pendingTransactions with the previous wallet’s data. Add the same s.walletManagement.address === address check used for accountStatus.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@demo/wallet-core/src/store/slices/walletManagementSlice.ts` around lines 868
- 905, The events update block in the walletManagementSlice loadEvents flow is
missing the wallet-address guard, so a stale request can write previous-wallet
data after switchWallet. Add the same s.walletManagement.address === address
check used for accountStatus around the set(...) logic that mutates events,
confirmedTraceIds, confirmedExternalHashes, pendingTransactions, and
eventsError, and only apply the response when the active wallet still matches
the request address.
Summary
Mobile-web layout, safe-area, viewport-height and bottom-sheet keyboard/scroll fixes for the wallet demo. Every change is behind a mobile breakpoint or an opt-in prop; desktop and the extension popup are unchanged.
Motivation / Bug
On-device testing (iOS Safari + Android Chrome) surfaced several mobile-only issues:
CenteredScreenfooter used a fixedpb-6; Android 3-button mode reportsenv(safe-area-inset-bottom)=0while its ~48dp bar still overlaps the viewport, andenv()didn't resolve at all withoutviewport-fit=cover.100vh, which excludes iOS Safari's dynamic toolbars, leaving a gap.body { position: fixed; top: -scrollY }; aposition: stickyheader is anchored to its scroll container, and a fixed<body>is no longer that container, so the header detaches.env(safe-area-inset-top), which in a browser tab (not a standalone PWA) tracks the address bar showing/hiding — a large, moving gap on both platforms.<form>, so iOS Safari's Return never submitted).repositionInputsmutates the sheet height/offset fromvisualViewportresize events: the sheet flew off-screen on the first iOS focus and got shoved up on Android; the dashboard could also bleed through above it.What changed
viewport-fit=coverinindex.html; theCenteredScreenfooter reservesmax(3rem, 1.5rem + env(safe-area-inset-bottom)); the dashboard layout switched tomin-h-dvh.apps/demo-wallet/src/core/components/shared/pinned-header/) — new top bar:position: fixedon mobile with a ResizeObserver-measured flow spacer,md:stickyon desktop; a small fixed top pad instead of the top inset.NewLayoutand the onboarding back button use it.setup-password-screen,unlock-screen) — inputs wrapped in a<form>whoseonSubmitmirrors the primary button (same validity guard),enterKeyHintset, plus a visually-hidden submit button (what actually makes iOS Safari's Return submit).modal.tsx,connect-dapp-modal.tsx,is-ios.ts) — akeyboardSafeopt-in: disable vaulrepositionInputson both platforms and lift the compact sheet with a manualvisualViewport-basedbottomoffset (useKeyboardInset) so the input and Connect button rest directly above the keyboard. Input-less sheets setnoBodyStylesso the scroll-lock can't break the now-fixed header; the iOS input sheet keeps the lock and restores scroll on close.Why this approach
fixed) header is the only thing immune to vaul'sposition:fixedbody lock; sticky provably detaches. Desktop keeps sticky because Radix Dialog locks scroll viaoverflow:hidden(not a fixed body), so the problem doesn't occur there and we avoid a fixed-vs-flow reflow on the wide layout.repositionInputs:repositionInputsis what caused the fly-off/shove; abottomoffset is orthogonal to vaul's transform-based open/close/drag, so the two don't fight, and the resize stream supplies smooth intermediate heights.env(safe-area-inset-top): that inset is address-bar-coupled in a browser tab, which is exactly the fluctuation observed.How it was verified
pnpm --filter demo-wallet typecheck(tsc, clean).Scope / safety
Every change is mobile-only (breakpoint- or
visualViewport/env()-guarded) or opt-in (keyboardSafe). The desktopDialogpath and the extension popup entry are untouched. No store or business logic changed.Follow-ups
Related: #485 (demo-wallet e2e). It should be rebased on top of these fixes before it merges.
Summary by CodeRabbit
New Features
Bug Fixes