Skip to content
This repository was archived by the owner on Jul 23, 2026. It is now read-only.

chore: webapp and mobile improve performance for earn - #2790

Closed
mmioana wants to merge 21 commits into
developfrom
feature/jum-609-webapp-and-mobile-improve-performance-for-earn
Closed

chore: webapp and mobile improve performance for earn#2790
mmioana wants to merge 21 commits into
developfrom
feature/jum-609-webapp-and-mobile-improve-performance-for-earn

Conversation

@mmioana

@mmioana mmioana commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Which Jira task belongs to this PR?

Closes https://linear.app/lifi-linear/issue/JUM-609/webapp-and-mobile-improve-performance-for-earn

Reports from 03.04.26
Screenshot 2026-04-03 at 15 47 44
Screenshot 2026-04-03 at 15 11 24

Note

On preview branch SEO results will be low as this page is not allowed to be indexed

Testing steps

  1. Head to https://pagespeed.web.dev/
  2. Compare results with production for /earn and /earn?tab=all
  3. Performance should be improved (might differ from screenshots depending on data fetching response time)
  4. Also test the filtering on both desktop and mobile

Why did I implement it this way?

Checklist before requesting a review

  • I have performed a self-review of my code
  • This pull request is as small as possible and only tackles one problem
  • I have added tests that cover the functionality / test the bug
  • If this changed the API, I have updated the documentation
  • I have provided QA instructions for the feature / fix implemented in this PR (if applicable)
  • I have provided instructions for any environment / deployment changes that this PR needs when merged

Summary by CodeRabbit

  • New Features

    • Unified "Filters & Sort" UI with responsive modal/drawer, a new toggle filter type, and added translation "Filters & Sort".
  • Refactor

    • Improved earn experience and performance via separated data/filter flows, cached initial earn data, dynamic (lazy) loading for heavy modals/components, and memoized cards to reduce unnecessary re-renders.
  • Chores

    • Added ES-module lodash runtime support and expanded allowed remote image hosts for optimized image delivery.

@mmioana mmioana self-assigned this Apr 2, 2026
@vercel

vercel Bot commented Apr 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
jumper-exchange Ready Ready Preview, Comment Apr 29, 2026 2:55pm
jumper-exchange-storybook Ready Ready Preview, Comment Apr 29, 2026 2:55pm

Request Review

@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Splits the Earn filtering context into separate data and filter providers, introduces a cached server helper for initial opportunities, shifts several UI pieces to client-only dynamic imports, adds toggle filter categories and toggle view, memoizes card components, and updates image remote patterns and lodash imports.

Changes

Cohort / File(s) Summary
Config & Dependencies
next.config.mjs, package.json
Expanded Next.js image remotePatterns with multiple new hosts/paths; added lodash-es and @types/lodash-es.
Page / Server fetch
src/app/[lng]/earn/page.tsx, src/app/[lng]/earn/layout.tsx, src/app/lib/getEarnInitialAllOpportunitiesCached.ts
Removed client directive from layout; page now awaits and passes initialAllOpportunities; added cached SSR helper (unstable_cache) with 1h revalidate and empty-data fallback.
Context & Provider split
src/app/ui/earn/EarnFilteringContext.tsx
Replaced single EarnFilteringContext with two contexts/hooks (useEarnData, useEarnFilter); EarnFilteringProvider now requires initialAllOpportunities; providers return separate memoized values; mock provider updated; imports switched to lodash-es.
Earn page & propagation
src/app/ui/earn/EarnsPage.tsx, src/app/ui/earn/index.ts, src/app/[lng]/earn/[slug]/page.tsx
EarnsPage prop updated to accept initialAllOpportunities and render client wrapper; removed some barrel re-exports and adjusted imports.
Opportunities components
src/app/ui/earn/EarnOpportunitiesAll/*
Added EarnOpportunitiesAllClient (dynamic, ssr:false), EarnOpportunitiesAll accepts initialAllOpportunities, EarnFilterBar loaded dynamically, skeleton marked client.
Empty-list consumers
src/app/ui/earn/EarnEmptyList/*.tsx
Updated components to consume useEarnData / useEarnFilter instead of removed useEarnFiltering.
Filter bar & hooks
src/components/EarnFilterBar/*, src/components/EarnFilterBar/hooks.tsx, src/components/EarnFilterBar/utils.ts
Consolidated All filter UI into EarnFilterBarContentAll (dynamic modal/drawer), removed tablet/desktop variants and EarnFilterSort, added useEarnFilterCategories, badge helpers, and changed rewards APY handler.
MultiLayer & toggle support
src/components/composite/MultiLayer/*
Added CategoryContentType.Toggle, ToggleLeafCategory, createToggleCategory, renderer branch, and new ToggleView component/view.
Modals → dynamic
src/components/composite/*Flow/*
Converted Deposit/RequestRedeem/Withdraw modals to dynamic imports (ssr:false) and mount them only when open; added aria-labels for deposit buttons.
Cards & perf
src/components/Cards/*, src/app/ui/earn/EarnOpportunitiesCards.tsx
Wrapped Hero/Earn cards with memo() + custom comparators; adjusted loading keys to item?.slug ?? \loading-${index}``.
Core primitives & styles
src/components/core/AvatarStack/AvatarItem.tsx, src/components/core/form/Select/Select.styles.ts
Added NextImageAdapter for Avatar image slot; introduced StyledSwitch styled Switch component.
Hooks & types
src/hooks/earn/*
Added EarnOpportunitiesWithParsedMeta type and set staleTime for relevant React Query hooks; small query option adjustments.
i18n & tests
src/i18n/*, tests/testData/earnPageFunctions.ts
Added earn.filter.filterSort translation; Playwright helpers updated to open filter modal and interact with modal-scoped menu items.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Browser as Browser (Client)
  participant Page as /[lng]/earn Page (Server)
  participant Cache as unstable_cache
  participant API as getOpportunitiesFiltered (API)
  participant Provider as EarnFilteringProvider (Client)
  participant DynamicUI as EarnOpportunitiesAllClient (Client)

  Browser->>Page: GET /[lng]/earn
  Page->>Cache: call cached helper (key: earn-initial-all-opportunities)
  alt cache miss
    Cache->>API: getOpportunitiesFiltered({})
    API-->>Cache: response.data
  end
  Cache-->>Page: return cached result (data or fallback)
  Page-->>Browser: render page with initialAllOpportunities prop
  Browser->>DynamicUI: mount EarnOpportunitiesAllClient(initialAllOpportunities)
  DynamicUI->>Provider: initialize EarnData & EarnFilter providers with initial data
  Browser->>DynamicUI: user opens Filters
  DynamicUI->>DynamicUI: load dynamic Filter modal/drawer (ssr:false)
  DynamicUI->>Provider: apply/clear filters (useEarnFilter)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • laurentsenta
  • oktapodia

"🐰
I split the filters, cached the field,
Hopped server-side for the data yield.
Toggles, modals, memoized cards in flight,
I tossed the console and hugged the night. 🥕"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the changeset—improving performance for the earn page across webapp and mobile platforms.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/jum-609-webapp-and-mobile-improve-performance-for-earn

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

✅ All snapshot tests passed

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Playwright test results

failed  1 failed
passed  41 passed
flaky  2 flaky
skipped  1 skipped

Details

stats  45 tests across 9 suites
duration  12 minutes, 7 seconds
commit  b0e97e5

Failed tests

chromium › earnPage.spec.ts › Should be able to navigate to the "Your Positions" tab › Should be able to navigate to the "Your Positions" tab (Qase ID: 56)

Flaky tests

chromium › mainMenu.spec.ts › Main Menu flows › Should be able to navigate to the Jumper Learn (Qase ID: 22)
chromium › mainMenu.spec.ts › Main Menu flows › Should be able to navigate to Discord (Qase ID: 17)

Skipped tests

chromium › themeManipulation.spec.ts › Switch between dark and light theme and check the background color › Partner theme should appear in theme menu and apply background color (Qase ID: 49)

📋 View Detailed Qase Report

@mmioana mmioana changed the title Feature/jum 609 webapp and mobile improve performance for earn chore: webapp and mobile improve performance for earn Apr 3, 2026
@mmioana
mmioana force-pushed the feature/jum-609-webapp-and-mobile-improve-performance-for-earn branch from 304ca17 to 225d2cb Compare April 28, 2026 15:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/components/Cards/HeroEarnCard/HeroEarnCard.tsx (1)

170-178: ⚠️ Potential issue | 🟠 Major

Memo comparator misses rendered props.

The comparator only checks data?.slug, but the component renders data.asset, data.protocol, data.forYou, data.tags, data.latest.apy, data.name, data.lpToken, and primaryAction. If any of these change while slug remains stable, the memo blocks necessary re-renders, leaving stale content.

Suggested fix: use default shallow comparison
-export const HeroEarnCard = memo(HeroEarnCardBase, (prev, next) => {
-  return (
-    prev.data?.slug === next.data?.slug &&
-    prev.isLoading === next.isLoading &&
-    prev.copy === next.copy &&
-    prev.isMain === next.isMain &&
-    prev.href === next.href
-  );
-});
+export const HeroEarnCard = memo(HeroEarnCardBase);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Cards/HeroEarnCard/HeroEarnCard.tsx` around lines 170 - 178,
The custom memo comparator for HeroEarnCard (wrapping HeroEarnCardBase) only
compares data?.slug and a few props, causing stale renders when other rendered
fields (e.g., data.asset, data.protocol, data.forYou, data.tags,
data.latest.apy, data.name, data.lpToken, primaryAction) change; fix by removing
the custom comparator so React.memo uses the default shallow comparison (i.e.,
export const HeroEarnCard = memo(HeroEarnCardBase)) or, alternatively, expand
the comparator to explicitly compare all rendered props (data.asset,
data.protocol, data.forYou, data.tags, data.latest?.apy, data.name,
data.lpToken, primaryAction and the existing fields) to ensure re-renders occur
when any rendered value changes.
src/components/EarnFilterBar/hooks.tsx (2)

338-339: ⚠️ Potential issue | 🟠 Major

Clear all may not reset pending UI state.

onClear is wired directly to handleClearAllFilters, which clears the applied filters but may not reset the pendingValues tracked by usePendingFilters. If the applied state is already empty, pressing Clear all after making pending selections in the modal could leave the pending UI unchanged.

Verify that usePendingFilters.clearAll internally resets pending state, or explicitly reset it alongside the applied clear.

#!/bin/bash
# Check how usePendingFilters handles clearAll and whether it resets pending state
ast-grep --pattern 'export function usePendingFilters($$$) {
  $$$
}'

rg -n -A 20 'clearAll' src/components/composite/MultiLayer/hooks.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EarnFilterBar/hooks.tsx` around lines 338 - 339, The Clear all
button currently calls handleClearAllFilters but may leave pending UI selections
intact; update the onClear flow so it also resets the pending state from
usePendingFilters (call the clearAll/reset method on the pending filters
instance) — either modify handleClearAllFilters to invoke
pendingFilters.clearAll (or pendingFilters.resetPending) or wrap
handleClearAllFilters so onClear calls both handleClearAllFilters() and
pendingFilters.clearAll(); ensure you reference the pending filter hook instance
used in this file (usePendingFilters / pendingFilters) and the onClear handler
so both applied and pending states are cleared.

323-336: ⚠️ Potential issue | 🟠 Major

Normalize filter values to null when empty/default.

The onApply handler passes empty arrays and full-range values directly instead of using null to indicate "unset". This inconsistency with the established convention (where null means "no filter") can leave stale filter state in context and query params.

Suggested fix
     onApply: (values) => {
-      const minRewardsAPY = values.rewardsAPY ? 0.0 : undefined;
+      const hasApyFilter =
+        values.apy[0] !== apyMin || values.apy[1] !== apyMax;
+      const hasTvlFilter =
+        values.tvl[0] !== tvlMin || values.tvl[1] !== tvlMax;
       handleApplyAllFilters({
-        chains: values.chains.map(Number) ?? [],
-        protocols: values.protocols ?? [],
-        tags: values?.tags ?? [],
-        assets: values.assets ?? [],
-        minAPY: values.apy[0] / 100,
-        maxAPY: values.apy[1] / 100,
-        minTVL: values.tvl[0],
-        maxTVL: values.tvl[1],
-        minRewardsAPY,
+        chains: values.chains.length ? values.chains.map(Number) : null,
+        protocols: values.protocols.length ? values.protocols : null,
+        tags: values.tags.length ? values.tags : null,
+        assets: values.assets.length ? values.assets : null,
+        minAPY: hasApyFilter ? values.apy[0] / 100 : null,
+        maxAPY: hasApyFilter ? values.apy[1] / 100 : null,
+        minTVL: hasTvlFilter ? values.tvl[0] : null,
+        maxTVL: hasTvlFilter ? values.tvl[1] : null,
+        minRewardsAPY: values.rewardsAPY ? 0.0 : null,
       });
       handleSortBy(values.sortBy ?? '');
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EarnFilterBar/hooks.tsx` around lines 323 - 336, The onApply
handler should map empty/default inputs to null before calling
handleApplyAllFilters to avoid leaving stale filters: update the onApply block
in hooks.tsx so that chains, protocols, tags, and assets become null when their
arrays are empty (instead of []), convert minAPY/maxAPY to null when values.apy
represents the full slider range (so you only pass a number when the range is
actually narrowed), convert minTVL/maxTVL to null when values.tvl represents the
full TVL range, and set minRewardsAPY to null (not undefined) when rewardsAPY is
not set; keep calling handleSortBy(values.sortBy ?? '') but optionally normalize
empty string to null if your app expects that. Ensure these checks reference the
existing symbols values.apy, values.tvl, values.rewardsAPY, and the call
handleApplyAllFilters to implement the normalization.
🧹 Nitpick comments (2)
src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx (1)

9-13: Make space-between deterministic with explicit width.

At Line 11, justifyContent: 'space-between' depends on available row width; adding width: '100%' makes spacing behavior consistent.

Suggested tweak
         sx={{
           display: 'flex',
           justifyContent: 'space-between',
           alignItems: 'center',
+          width: '100%',
         }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx` around lines 9 - 13,
The layout uses justifyContent: 'space-between' inside the sx prop of
EarnFilterBarSkeleton which yields variable spacing; update the sx object for
the container (the object that currently contains display: 'flex',
justifyContent: 'space-between', alignItems: 'center') to include width: '100%'
so the space-between calculation is deterministic and consistent across layouts.
src/components/EarnFilterBar/EarnFilterBar.tsx (1)

57-67: Minor: Redundant nested Stack wrapper.

The inner Stack (lines 57-67) has identical styling to its parent Stack (lines 48-55) and only wraps EarnListMode. This nesting appears unnecessary.

Suggested simplification
           <Stack
             direction="row"
             sx={{
               gap: 1,
               display: 'flex',
               alignItems: 'center',
               flexShrink: 0,
             }}
           >
-            <Stack
-              direction="row"
-              sx={{
-                gap: 1,
-                display: 'flex',
-                alignItems: 'center',
-                flexShrink: 0,
-              }}
-            >
-              <EarnListMode variant={variant} setVariant={setVariant} />
-            </Stack>
+            <EarnListMode variant={variant} setVariant={setVariant} />
             {!isForYouTab && <EarnFilterBarContentAll />}
           </Stack>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EarnFilterBar/EarnFilterBar.tsx` around lines 57 - 67, The
inner Stack wrapper around EarnListMode in EarnFilterBar is redundant because it
duplicates the parent's sx props; remove the nested Stack and render
<EarnListMode variant={variant} setVariant={setVariant} /> directly inside the
parent Stack (keep the parent Stack's sx and children order) to simplify the
component and avoid unnecessary DOM nodes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Cards/EarnCard/EarnCard.tsx`:
- Around line 17-25: The memo comparator on EarnCard (memo(EarnCardBase, ...))
currently only compares variant, data.slug, isLoading, isMissingPosition and
href, omitting primaryAction, headerBadge, and fullWidth which can change
rendering; fix by either (A) adding comparisons for prev.primaryAction ===
next.primaryAction, prev.headerBadge === next.headerBadge, and prev.fullWidth
=== next.fullWidth inside the custom comparator for EarnCard, or (B) remove the
custom comparator entirely so React.memo uses the default shallow comparison for
EarnCardBase to catch changes to those props.

In `@src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx`:
- Around line 16-22: The skeleton currently renders two BaseSkeleton pills
(Array.from({ length: 2 })) at height 32 which doesn't match the desktop
filter's three MD tabs and causes CLS; update EarnFilterBarSkeleton (where
BaseSkeleton is mapped) to render three placeholders (use length: 3) and adjust
the BaseSkeleton props (variant="rounded", width, height) to match the desktop
MD tab dimensions used by the real tabs so the skeleton layout matches the final
UI.

---

Duplicate comments:
In `@src/components/Cards/HeroEarnCard/HeroEarnCard.tsx`:
- Around line 170-178: The custom memo comparator for HeroEarnCard (wrapping
HeroEarnCardBase) only compares data?.slug and a few props, causing stale
renders when other rendered fields (e.g., data.asset, data.protocol,
data.forYou, data.tags, data.latest.apy, data.name, data.lpToken, primaryAction)
change; fix by removing the custom comparator so React.memo uses the default
shallow comparison (i.e., export const HeroEarnCard = memo(HeroEarnCardBase))
or, alternatively, expand the comparator to explicitly compare all rendered
props (data.asset, data.protocol, data.forYou, data.tags, data.latest?.apy,
data.name, data.lpToken, primaryAction and the existing fields) to ensure
re-renders occur when any rendered value changes.

In `@src/components/EarnFilterBar/hooks.tsx`:
- Around line 338-339: The Clear all button currently calls
handleClearAllFilters but may leave pending UI selections intact; update the
onClear flow so it also resets the pending state from usePendingFilters (call
the clearAll/reset method on the pending filters instance) — either modify
handleClearAllFilters to invoke pendingFilters.clearAll (or
pendingFilters.resetPending) or wrap handleClearAllFilters so onClear calls both
handleClearAllFilters() and pendingFilters.clearAll(); ensure you reference the
pending filter hook instance used in this file (usePendingFilters /
pendingFilters) and the onClear handler so both applied and pending states are
cleared.
- Around line 323-336: The onApply handler should map empty/default inputs to
null before calling handleApplyAllFilters to avoid leaving stale filters: update
the onApply block in hooks.tsx so that chains, protocols, tags, and assets
become null when their arrays are empty (instead of []), convert minAPY/maxAPY
to null when values.apy represents the full slider range (so you only pass a
number when the range is actually narrowed), convert minTVL/maxTVL to null when
values.tvl represents the full TVL range, and set minRewardsAPY to null (not
undefined) when rewardsAPY is not set; keep calling handleSortBy(values.sortBy
?? '') but optionally normalize empty string to null if your app expects that.
Ensure these checks reference the existing symbols values.apy, values.tvl,
values.rewardsAPY, and the call handleApplyAllFilters to implement the
normalization.

---

Nitpick comments:
In `@src/components/EarnFilterBar/EarnFilterBar.tsx`:
- Around line 57-67: The inner Stack wrapper around EarnListMode in
EarnFilterBar is redundant because it duplicates the parent's sx props; remove
the nested Stack and render <EarnListMode variant={variant}
setVariant={setVariant} /> directly inside the parent Stack (keep the parent
Stack's sx and children order) to simplify the component and avoid unnecessary
DOM nodes.

In `@src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx`:
- Around line 9-13: The layout uses justifyContent: 'space-between' inside the
sx prop of EarnFilterBarSkeleton which yields variable spacing; update the sx
object for the container (the object that currently contains display: 'flex',
justifyContent: 'space-between', alignItems: 'center') to include width: '100%'
so the space-between calculation is deterministic and consistent across layouts.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c936d523-1b2b-4174-8d20-72848eb46173

📥 Commits

Reviewing files that changed from the base of the PR and between 304ca17 and 225d2cb.

⛔ Files ignored due to path filters (6)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • src/components/Cards/EarnCard/__snapshots__/EarnCard.snapshot.spec.tsx.snap is excluded by !**/*.snap
  • src/components/Cards/HeroEarnCard/__snapshots__/HeroEarnCard.snapshot.spec.tsx.snap is excluded by !**/*.snap
  • src/components/composite/WalletBalanceCard/__snapshots__/WalletBalanceCard.snapshot.spec.tsx.snap is excluded by !**/*.snap
  • src/components/composite/cards/ProcessingTransactionCard/__snapshots__/ProcessingTransactionCard.snapshot.spec.tsx.snap is excluded by !**/*.snap
  • src/components/core/AvatarStack/__snapshots__/AvatarStack.snapshot.spec.tsx.snap is excluded by !**/*.snap
📒 Files selected for processing (48)
  • next.config.mjs
  • package.json
  • src/app/[lng]/earn/[slug]/page.tsx
  • src/app/[lng]/earn/layout.tsx
  • src/app/[lng]/earn/page.tsx
  • src/app/lib/getEarnInitialAllOpportunitiesCached.ts
  • src/app/ui/earn/EarnEmptyList/EarnEmptyList.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListAllMarkets.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListForYou.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListYourPositions.tsx
  • src/app/ui/earn/EarnFilteringContext.tsx
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAll.tsx
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAllClient.tsx
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAllSkeleton.tsx
  • src/app/ui/earn/EarnOpportunitiesCards.tsx
  • src/app/ui/earn/EarnsPage.tsx
  • src/app/ui/earn/EarnsPageSkeleton.tsx
  • src/app/ui/earn/filterOpportunities.ts
  • src/app/ui/earn/index.ts
  • src/components/Cards/EarnCard/EarnCard.tsx
  • src/components/Cards/HeroEarnCard/HeroEarnCard.tsx
  • src/components/Cards/ProtocolCard/ProtocolCard.tsx
  • src/components/EarnFilterBar/EarnFilterBar.stories.tsx
  • src/components/EarnFilterBar/EarnFilterBar.tsx
  • src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx
  • src/components/EarnFilterBar/components/EarnFilterBarContentForYou.tsx
  • src/components/EarnFilterBar/components/EarnFilterSort.tsx
  • src/components/EarnFilterBar/components/EarnListMode.tsx
  • src/components/EarnFilterBar/hooks.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAll.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAllDesktop.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAllTablet.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterViewDesktop.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterViewTablet.tsx
  • src/components/EarnFilterBar/utils.ts
  • src/components/composite/DepositFlow/DepositFlow.tsx
  • src/components/composite/MultiLayer/MultiLayer.types.ts
  • src/components/composite/MultiLayer/components/LeafCategoryRenderer.tsx
  • src/components/composite/MultiLayer/utils.ts
  • src/components/composite/MultiLayer/views/ToggleView.tsx
  • src/components/composite/RequestRedeemFlow/RequestRedeemFlow.tsx
  • src/components/composite/WithdrawFlow/WithdrawFlow.tsx
  • src/components/core/AvatarStack/AvatarItem.tsx
  • src/components/core/form/Select/Select.styles.ts
  • src/hooks/earn/useEarnFilterOpportunities.ts
  • src/hooks/earn/useEarnTopOpportunities.ts
  • src/i18n/resources.d.ts
  • src/i18n/translations/en/translation.json
💤 Files with no reviewable changes (6)
  • src/app/[lng]/earn/layout.tsx
  • src/app/ui/earn/index.ts
  • src/app/ui/earn/EarnsPageSkeleton.tsx
  • src/components/EarnFilterBar/components/EarnFilterSort.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAllDesktop.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAllTablet.tsx
✅ Files skipped from review due to trivial changes (13)
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAllSkeleton.tsx
  • src/i18n/translations/en/translation.json
  • src/app/ui/earn/filterOpportunities.ts
  • src/components/composite/MultiLayer/utils.ts
  • src/components/EarnFilterBar/components/EarnListMode.tsx
  • src/app/[lng]/earn/[slug]/page.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterViewDesktop.tsx
  • package.json
  • src/app/ui/earn/EarnOpportunitiesCards.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterViewTablet.tsx
  • src/components/EarnFilterBar/components/EarnFilterBarContentForYou.tsx
  • next.config.mjs
  • src/hooks/earn/useEarnFilterOpportunities.ts
🚧 Files skipped from review as they are similar to previous changes (17)
  • src/components/Cards/ProtocolCard/ProtocolCard.tsx
  • src/hooks/earn/useEarnTopOpportunities.ts
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListForYou.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyList.tsx
  • src/app/lib/getEarnInitialAllOpportunitiesCached.ts
  • src/components/composite/WithdrawFlow/WithdrawFlow.tsx
  • src/i18n/resources.d.ts
  • src/components/composite/MultiLayer/MultiLayer.types.ts
  • src/components/EarnFilterBar/utils.ts
  • src/components/core/form/Select/Select.styles.ts
  • src/components/composite/DepositFlow/DepositFlow.tsx
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAllClient.tsx
  • src/components/composite/MultiLayer/views/ToggleView.tsx
  • src/components/EarnFilterBar/layouts/EarnFilterBarContentAll.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListYourPositions.tsx
  • src/app/ui/earn/EarnOpportunitiesAll/EarnOpportunitiesAll.tsx
  • src/app/ui/earn/EarnEmptyList/EarnEmptyListAllMarkets.tsx

Comment on lines +17 to +25
export const EarnCard = memo(EarnCardBase, (prev, next) => {
return (
prev.variant === next.variant &&
prev.data?.slug === next.data?.slug &&
prev.isLoading === next.isLoading &&
prev.isMissingPosition === next.isMissingPosition &&
prev.href === next.href
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Memo comparator misses rendered props primaryAction, headerBadge, and fullWidth.

The custom comparator doesn't check primaryAction, headerBadge, or fullWidth, all of which are passed to the variant components and affect rendering. Changes to these props while slug remains stable will be ignored, causing stale UI.

Option 1: Include missing props in comparator
 export const EarnCard = memo(EarnCardBase, (prev, next) => {
   return (
     prev.variant === next.variant &&
     prev.data?.slug === next.data?.slug &&
     prev.isLoading === next.isLoading &&
     prev.isMissingPosition === next.isMissingPosition &&
-    prev.href === next.href
+    prev.href === next.href &&
+    prev.fullWidth === next.fullWidth &&
+    prev.primaryAction === next.primaryAction &&
+    prev.headerBadge === next.headerBadge
   );
 });
Option 2: Use default shallow comparison
-export const EarnCard = memo(EarnCardBase, (prev, next) => {
-  return (
-    prev.variant === next.variant &&
-    prev.data?.slug === next.data?.slug &&
-    prev.isLoading === next.isLoading &&
-    prev.isMissingPosition === next.isMissingPosition &&
-    prev.href === next.href
-  );
-});
+export const EarnCard = memo(EarnCardBase);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const EarnCard = memo(EarnCardBase, (prev, next) => {
return (
prev.variant === next.variant &&
prev.data?.slug === next.data?.slug &&
prev.isLoading === next.isLoading &&
prev.isMissingPosition === next.isMissingPosition &&
prev.href === next.href
);
});
export const EarnCard = memo(EarnCardBase, (prev, next) => {
return (
prev.variant === next.variant &&
prev.data?.slug === next.data?.slug &&
prev.isLoading === next.isLoading &&
prev.isMissingPosition === next.isMissingPosition &&
prev.href === next.href &&
prev.fullWidth === next.fullWidth &&
prev.primaryAction === next.primaryAction &&
prev.headerBadge === next.headerBadge
);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Cards/EarnCard/EarnCard.tsx` around lines 17 - 25, The memo
comparator on EarnCard (memo(EarnCardBase, ...)) currently only compares
variant, data.slug, isLoading, isMissingPosition and href, omitting
primaryAction, headerBadge, and fullWidth which can change rendering; fix by
either (A) adding comparisons for prev.primaryAction === next.primaryAction,
prev.headerBadge === next.headerBadge, and prev.fullWidth === next.fullWidth
inside the custom comparator for EarnCard, or (B) remove the custom comparator
entirely so React.memo uses the default shallow comparison for EarnCardBase to
catch changes to those props.

Comment on lines +16 to +22
{Array.from({ length: 2 }).map((_, index) => (
<BaseSkeleton
key={index}
variant="rounded"
width={104}
height={32}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Skeleton tab placeholders don’t match the desktop tabs and can introduce avoidable CLS.

Line 16-22 renders only 2 pills at 32px height, while the desktop filter view renders 3 MD tabs. This mismatch can cause visible layout shift when the real tabs hydrate/load, which is counterproductive for this performance-focused PR.

Proposed adjustment
-          {Array.from({ length: 2 }).map((_, index) => (
+          {Array.from({ length: 3 }).map((_, index) => (
             <BaseSkeleton
               key={index}
               variant="rounded"
               width={104}
-              height={32}
+              height={40}
             />
           ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{Array.from({ length: 2 }).map((_, index) => (
<BaseSkeleton
key={index}
variant="rounded"
width={104}
height={32}
/>
{Array.from({ length: 3 }).map((_, index) => (
<BaseSkeleton
key={index}
variant="rounded"
width={104}
height={40}
/>
))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EarnFilterBar/EarnFilterBarSkeleton.tsx` around lines 16 - 22,
The skeleton currently renders two BaseSkeleton pills (Array.from({ length: 2
})) at height 32 which doesn't match the desktop filter's three MD tabs and
causes CLS; update EarnFilterBarSkeleton (where BaseSkeleton is mapped) to
render three placeholders (use length: 3) and adjust the BaseSkeleton props
(variant="rounded", width, height) to match the desktop MD tab dimensions used
by the real tabs so the skeleton layout matches the final UI.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/testData/earnPageFunctions.ts (1)

62-65: Prefer condition-based waiting over fixed 1s sleep after apply.

waitForTimeout(1000) can still be flaky in slower CI runs. Wait for a deterministic UI condition instead.

Suggested fix
   // Apply the filter and close the modal
   await page.getByTestId(`${DESKTOP_FILTER_MODAL}-apply-button`).click();
-
-  await page.waitForTimeout(1000);
+  await expect(
+    page.getByTestId(`${DESKTOP_FILTER_MODAL}-apply-button`),
+  ).toBeHidden();
+  await expect(page.getByTestId('earn-opportunities-cards-grid')).toBeVisible();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/testData/earnPageFunctions.ts` around lines 62 - 65, Replace the fixed
1s sleep after clicking the apply button with a condition-based wait: after
clicking the locator `${DESKTOP_FILTER_MODAL}-apply-button`, wait for the filter
modal to be removed/hidden or for a deterministic UI change that indicates
filters were applied (for example wait for the `${DESKTOP_FILTER_MODAL}` test-id
to be hidden or for the results list/test-id that reflects applied filters to be
visible/updated). Update the test that contains the apply-click (the code using
`${DESKTOP_FILTER_MODAL}-apply-button`) to use Playwright's locator.waitFor or
page.waitForSelector with an appropriate state ('hidden' or 'visible') instead
of waitForTimeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/testData/earnPageFunctions.ts`:
- Around line 13-15: The closeFilterModal function currently only presses Escape
and can race with later actions; update closeFilterModal to press Escape and
then wait for the filter modal's apply button to become hidden/removed (e.g.,
waitForSelector('selector-for-apply-button', { state: 'hidden' }) or equivalent)
so callers can assume the modal is fully closed before proceeding; reference the
function name closeFilterModal and the apply-button element when applying the
wait.

---

Nitpick comments:
In `@tests/testData/earnPageFunctions.ts`:
- Around line 62-65: Replace the fixed 1s sleep after clicking the apply button
with a condition-based wait: after clicking the locator
`${DESKTOP_FILTER_MODAL}-apply-button`, wait for the filter modal to be
removed/hidden or for a deterministic UI change that indicates filters were
applied (for example wait for the `${DESKTOP_FILTER_MODAL}` test-id to be hidden
or for the results list/test-id that reflects applied filters to be
visible/updated). Update the test that contains the apply-click (the code using
`${DESKTOP_FILTER_MODAL}-apply-button`) to use Playwright's locator.waitFor or
page.waitForSelector with an appropriate state ('hidden' or 'visible') instead
of waitForTimeout.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1c425f1-f380-45e5-a1a3-5c06636c9df3

📥 Commits

Reviewing files that changed from the base of the PR and between 225d2cb and b0e97e5.

📒 Files selected for processing (1)
  • tests/testData/earnPageFunctions.ts

Comment on lines +13 to +15
async function closeFilterModal(page: Page) {
await page.keyboard.press('Escape');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n tests/testData/earnPageFunctions.ts | head -30

Repository: jumperexchange/jumper-exchange

Length of output: 1212


🏁 Script executed:

rg "DESKTOP_FILTER_MODAL" -A 2 -B 2

Repository: jumperexchange/jumper-exchange

Length of output: 1032


🏁 Script executed:

rg "closeFilterModal" -A 5 -B 2

Repository: jumperexchange/jumper-exchange

Length of output: 3135


🏁 Script executed:

rg "apply-button" -B 5 -A 5 tests/

Repository: jumperexchange/jumper-exchange

Length of output: 1829


🏁 Script executed:

rg "close.*modal\|modal.*close" -i tests/testData/earnPageFunctions.ts -B 3 -A 3

Repository: jumperexchange/jumper-exchange

Length of output: 56


🏁 Script executed:

rg "modal.*hidden\|hidden.*modal" -i tests/ -B 2 -A 2

Repository: jumperexchange/jumper-exchange

Length of output: 56


🏁 Script executed:

rg "Escape.*modal\|modal.*Escape" -i tests/ -B 3 -A 3

Repository: jumperexchange/jumper-exchange

Length of output: 56


🏁 Script executed:

rg "earn-filters-desktop-modal" -B 2 -A 5

Repository: jumperexchange/jumper-exchange

Length of output: 1421


🏁 Script executed:

grep -rn "toBeHidden\|waitFor.*hidden" tests/ | head -20

Repository: jumperexchange/jumper-exchange

Length of output: 186


🏁 Script executed:

cat -n tests/testData/earnPageFunctions.ts | sed -n '35,65p'

Repository: jumperexchange/jumper-exchange

Length of output: 1330


🏁 Script executed:

rg "getByTestId.*apply-button" tests/testData/earnPageFunctions.ts -B 3 -A 3

Repository: jumperexchange/jumper-exchange

Length of output: 308


🏁 Script executed:

rg "Modal" src/components/EarnFilterBar -A 10 | head -40

Repository: jumperexchange/jumper-exchange

Length of output: 2484


🏁 Script executed:

rg "onClose\|Escape" src/components/EarnFilterBar -B 2 -A 2

Repository: jumperexchange/jumper-exchange

Length of output: 56


🏁 Script executed:

fd -name "FilterSortModal*" src/

Repository: jumperexchange/jumper-exchange

Length of output: 305


🏁 Script executed:

rg "keyboard.press.*Escape\|onClose" src/components/composite/FilterSortModal -B 2 -A 2

Repository: jumperexchange/jumper-exchange

Length of output: 56


🏁 Script executed:

fd "FilterSortModal" src/

Repository: jumperexchange/jumper-exchange

Length of output: 246


🏁 Script executed:

rg "onClose" src/components/composite/FilterSortModal -B 3 -A 3

Repository: jumperexchange/jumper-exchange

Length of output: 1802


🏁 Script executed:

cat -n src/components/composite/FilterSortModal/FilterSortModal.tsx | head -80

Repository: jumperexchange/jumper-exchange

Length of output: 2971


🏁 Script executed:

rg "ModalContainer" src/components/composite -B 2 -A 10 | grep -A 10 "FilterSortModal"

Repository: jumperexchange/jumper-exchange

Length of output: 4478


🏁 Script executed:

cat -n src/components/core/modals/ModalContainer/ModalContainer.tsx | head -60

Repository: jumperexchange/jumper-exchange

Length of output: 1818


🏁 Script executed:

rg "useFullScreenDrawer" src/components/core/FullScreenDrawer -B 2 -A 15 | head -50

Repository: jumperexchange/jumper-exchange

Length of output: 1288


🏁 Script executed:

cat -n src/components/core/FullScreenDrawer/hooks.ts

Repository: jumperexchange/jumper-exchange

Length of output: 765


🏁 Script executed:

rg "toBeVisible\|toBeHidden" tests/testData/earnPageFunctions.ts

Repository: jumperexchange/jumper-exchange

Length of output: 56


Add postcondition assertion to closeFilterModal to prevent race conditions.

closeFilterModal sends Escape key but doesn't verify the modal is closed, which can cause race conditions in subsequent code. The apply-button should become hidden when the modal closes.

Suggested fix
 async function closeFilterModal(page: Page) {
   await page.keyboard.press('Escape');
+  await expect(
+    page.getByTestId(`${DESKTOP_FILTER_MODAL}-apply-button`),
+  ).toBeHidden();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function closeFilterModal(page: Page) {
await page.keyboard.press('Escape');
}
async function closeFilterModal(page: Page) {
await page.keyboard.press('Escape');
await expect(
page.getByTestId(`${DESKTOP_FILTER_MODAL}-apply-button`),
).toBeHidden();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/testData/earnPageFunctions.ts` around lines 13 - 15, The
closeFilterModal function currently only presses Escape and can race with later
actions; update closeFilterModal to press Escape and then wait for the filter
modal's apply button to become hidden/removed (e.g.,
waitForSelector('selector-for-apply-button', { state: 'hidden' }) or equivalent)
so callers can assume the modal is fully closed before proceeding; reference the
function name closeFilterModal and the apply-button element when applying the
wait.

case CategoryContentType.SingleSelect:
return <SingleSelectView category={category} slotProps={slotProps} />;

case CategoryContentType.Toggle:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toggle view button doesn't show as an active filter in the layout.
Other filters show 1 or N hen filtering for N assets or n chains.
The only rewards button does not which makes the UI less clear

? createToggleCategory({
id: 'rewardsAPY',
label: t('earn.filter.rewards.label'),
value: pendingValues.rewardsAPY,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding a badgeLabel here makes sense I think, UX wise

const initialAllOpportunities = await getEarnInitialAllOpportunitiesCached();

return (
<Suspense fallback={<EarnsPageSkeleton />}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not using EarnPageSkeleton anymore ?

@Kayanski Kayanski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, Very nice ! Apart the small changes prompted :)

@mmioana
mmioana marked this pull request as draft June 2, 2026 15:37
@mmioana

mmioana commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing this as there has been a different performance improvement done for earn. Will treat the filters refactoring in a different PR

@mmioana mmioana closed this Jul 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants