Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app/airdrops/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function AirdropsError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Airdrops" />;
}
10 changes: 10 additions & 0 deletions src/app/alerts/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function AlertsError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Alerts" />;
}
10 changes: 10 additions & 0 deletions src/app/contributors/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function ContributorsError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Contributors" />;
}
10 changes: 10 additions & 0 deletions src/app/farm/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function FarmError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Farm" />;
}
10 changes: 10 additions & 0 deletions src/app/history/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function HistoryError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="History" />;
}
10 changes: 10 additions & 0 deletions src/app/leaderboard/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function LeaderboardError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Leaderboard" />;
}
10 changes: 10 additions & 0 deletions src/app/prices/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function PricesError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Prices" />;
}
10 changes: 10 additions & 0 deletions src/app/webhooks/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import RouteError from "@/components/RouteError/RouteError";

export default function WebhooksError(props: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <RouteError {...props} routeName="Webhooks" />;
}
2 changes: 2 additions & 0 deletions src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletB
import Footer from "@/components/Footer/Footer";
import Navbar from "@/components/Navbar/Navbar";
import NetworkMismatchBanner from "@/components/NetworkMismatchBanner/NetworkMismatchBanner";
import RefetchIndicator from "@/components/RefetchIndicator/RefetchIndicator";
import RpcUnreachableBanner from "@/components/RpcUnreachableBanner/RpcUnreachableBanner";
import ContextProvider from "@/context";
import {
Expand Down Expand Up @@ -46,6 +47,7 @@ function LayoutWrapper({ children }: { children: React.ReactNode }) {
md: 0,
}}
>
<RefetchIndicator />
<Navbar />
<RpcUnreachableBanner />
<NetworkMismatchBanner />
Expand Down
46 changes: 46 additions & 0 deletions src/components/RefetchIndicator/RefetchIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { keyframes } from "@emotion/react";
import { Box } from "@chakra-ui/react";
import { useIsFetching } from "@tanstack/react-query";

const slide = keyframes`
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
`;

/**
* A thin bar at the top of the viewport while cached data is being refreshed
* (#499). Only queries that already hold data count, so first loads (which
* have their own spinners) don't trigger it; the old data stays on screen and
* this shows it is being updated. The animation is dropped for users who
* prefer reduced motion, leaving a static bar.
*/
export default function RefetchIndicator() {
const refetching =
useIsFetching({ predicate: (query) => query.state.data !== undefined }) > 0;

return (
<Box
aria-hidden="true"
position="fixed"
top={0}
left={0}
right={0}
h="2px"
zIndex="toast"
overflow="hidden"
pointerEvents="none"
opacity={refetching ? 1 : 0}
transition="opacity 0.2s ease"
>
<Box
h="100%"
w="40%"
bg="app.accent"
animation={refetching ? `${slide} 1.2s ease-in-out infinite` : undefined}
sx={{ "@media (prefers-reduced-motion: reduce)": { animation: "none", w: "100%" } }}
/>
</Box>
);
}
48 changes: 48 additions & 0 deletions src/components/RouteError/RouteError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { useEffect } from "react";
import { Box, Button, Heading, Text, VStack } from "@chakra-ui/react";
import { UnknownError, errorLogger } from "@/lib/error-handler";

interface RouteErrorProps {
error: Error & { digest?: string };
reset: () => void;
routeName: string;
}

/**
* Fallback rendered by a route's `error.tsx` (#497). Because it replaces only
* that route's content, a crash in one page leaves the navbar, footer and the
* rest of the app usable instead of showing an app-wide error page.
*/
export default function RouteError({ error, reset, routeName }: RouteErrorProps) {
useEffect(() => {
errorLogger.log(
new UnknownError(`The ${routeName} page encountered an error`, error),
`Route error boundary: ${routeName}`,
);
}, [error, routeName]);

return (
<Box
w="100%"
display="flex"
alignItems="center"
justifyContent="center"
px={4}
py={16}
color="app.text"
>
<VStack spacing={5} textAlign="center" maxW="md">
<Heading size="lg">Something went wrong</Heading>
<Text color="app.muted">
The {routeName} page ran into an unexpected error. The rest of the app
is still available.
</Text>
<Button onClick={reset} bg="app.accent" color="app.onAccent" _hover={{ opacity: 0.9 }}>
Try again
</Button>
</VStack>
</Box>
);
}
5 changes: 2 additions & 3 deletions src/components/TvlChart/TvlChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
Tooltip,
ResponsiveContainer,
} from "recharts";
import { sorobanService } from "@/lib/soroban";

const ACCENT = "#4ae292";

Expand All @@ -34,8 +33,8 @@ export default function TvlChart({ poolId }: TvlChartProps) {
useEffect(() => {
let cancelled = false;
setLoading(true);
sorobanService
.getPoolHistory(poolId, 7)
import("@/lib/soroban")
.then(({ sorobanService }) => sorobanService.getPoolHistory(poolId, 7))
.then((history) => {
if (!cancelled) {
setData(history);
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/useLeaderboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { sorobanService } from "@/lib/soroban";

export type SortKey = "credits" | "stake";

Expand All @@ -21,7 +20,10 @@ export function fetchLeaderboard(
sortKey: SortKey,
search?: string
): Promise<{ entries: LeaderboardEntry[]; total: number }> {
return sorobanService.getLeaderboard(offset, limit, sortKey, search);
// #498 — loaded on first use rather than at import time.
return import("@/lib/soroban").then(({ sorobanService }) =>
sorobanService.getLeaderboard(offset, limit, sortKey, search),
);
}

export function useLeaderboard(publicKey: string | null) {
Expand Down
41 changes: 22 additions & 19 deletions src/hooks/useSorobanQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,15 @@

import { useEffect, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getStellarBalance,
rpcServer,
simulateLockAssets,
simulateUnlockAssets,
sorobanService,
type UserPosition,
type TransactionResult,
} from '@/lib/soroban';
import type { UserPosition, TransactionResult } from '@/lib/soroban';
import { useStellarWallet } from '@/context/StellarWalletContext';
import { useToast } from '@chakra-ui/react';

// #498 — the Soroban service is loaded on first use instead of when this module
// is imported, so the RPC client is not created during app boot before any
// query needs it.
const loadSoroban = () => import('@/lib/soroban');

// Query Keys
export const QUERY_KEYS = {
POOLS: 'pools',
Expand All @@ -32,7 +29,7 @@ export const QUERY_KEYS = {
export const usePools = () => {
return useQuery({
queryKey: [QUERY_KEYS.POOLS],
queryFn: () => sorobanService.getFactoryPools(),
queryFn: async () => (await loadSoroban()).sorobanService.getFactoryPools(),
staleTime: 30000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 60000, // 1 minute
Expand All @@ -50,7 +47,7 @@ export const usePools = () => {
export const usePoolDepositors = (poolId: string, limit: number = 20) => {
return useQuery({
queryKey: ['poolDepositors', poolId, limit],
queryFn: () => sorobanService.getPoolDepositors(poolId, limit),
queryFn: async () => (await loadSoroban()).sorobanService.getPoolDepositors(poolId, limit),
enabled: !!poolId,
staleTime: 30000,
gcTime: 5 * 60 * 1000,
Expand All @@ -67,7 +64,7 @@ export const useUserPosition = (poolId: string, enabled: boolean = true) => {

return useQuery({
queryKey: [QUERY_KEYS.USER_POSITION, poolId, publicKey],
queryFn: () => sorobanService.getUserPosition(poolId, publicKey!),
queryFn: async () => (await loadSoroban()).sorobanService.getUserPosition(poolId, publicKey!),
enabled: enabled && !!publicKey && !!poolId,
staleTime: 15000, // 15 seconds
gcTime: 5 * 60 * 1000,
Expand All @@ -84,7 +81,7 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => {

return useQuery({
queryKey: [QUERY_KEYS.USER_CREDITS, poolId, publicKey],
queryFn: () => sorobanService.calculateUserCredits(poolId, publicKey!),
queryFn: async () => (await loadSoroban()).sorobanService.calculateUserCredits(poolId, publicKey!),
enabled: enabled && !!publicKey && !!poolId,
staleTime: 5000, // 5 seconds (credits change frequently)
gcTime: 5 * 60 * 1000,
Expand All @@ -96,7 +93,7 @@ export const useUserCredits = (poolId: string, enabled: boolean = true) => {
export const useStellarBalance = (publicKey?: string) => {
return useQuery({
queryKey: ['stellarBalance', publicKey],
queryFn: ({ signal }) => getStellarBalance(publicKey!, signal),
queryFn: async ({ signal }) => (await loadSoroban()).getStellarBalance(publicKey!, signal),
enabled: !!publicKey,
staleTime: 15000,
gcTime: 5 * 60 * 1000,
Expand Down Expand Up @@ -152,8 +149,8 @@ export const useLockAssetsFeePreview = (args: {
args.poolContractId,
debouncedAmount,
],
queryFn: () =>
simulateLockAssets({
queryFn: async () =>
(await loadSoroban()).simulateLockAssets({
publicKey: args.publicKey!,
poolContractId: args.poolContractId!,
amount: debouncedAmount,
Expand Down Expand Up @@ -203,8 +200,8 @@ export const useUnlockAssetsFeePreview = (args: {
args.poolContractId,
debouncedAmount,
],
queryFn: () =>
simulateUnlockAssets({
queryFn: async () =>
(await loadSoroban()).simulateUnlockAssets({
publicKey: args.publicKey!,
poolContractId: args.poolContractId!,
amount: debouncedAmount,
Expand Down Expand Up @@ -250,6 +247,7 @@ export const useLockAssets = (options?: {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected. Please connect Freighter before depositing.');
}
const { sorobanService } = await loadSoroban();
const result = await sorobanService.lockAssets(
poolId,
publicKey,
Expand Down Expand Up @@ -324,6 +322,7 @@ export const useUnlockAssets = () => {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected');
}
const { sorobanService } = await loadSoroban();
return sorobanService.unlockAssets(
poolId,
publicKey,
Expand Down Expand Up @@ -404,6 +403,7 @@ export const useSetBoost = () => {
if (!walletApi || !publicKey) {
throw new Error('Wallet not connected');
}
const { sorobanService } = await loadSoroban();
return sorobanService.setBoost(poolId, publicKey, allocationPercentage, walletApi);
},
onSuccess: (result: TransactionResult, variables) => {
Expand Down Expand Up @@ -460,6 +460,7 @@ export const useAllUserPositions = () => {
queryFn: async () => {
if (!publicKey || !pools) return [];

const { sorobanService } = await loadSoroban();
const positions = await Promise.allSettled(
pools.map(pool => sorobanService.getUserPosition(pool.id, publicKey))
);
Expand Down Expand Up @@ -490,6 +491,7 @@ export const useTotalUserCredits = () => {
queryFn: async () => {
if (!publicKey || !pools) return '0';

const { sorobanService } = await loadSoroban();
const credits = await Promise.allSettled(
pools.map(pool => sorobanService.calculateUserCredits(pool.id, publicKey))
);
Expand Down Expand Up @@ -566,6 +568,7 @@ export function usePlatformStats(initialData?: UIPlatformStats) {
return useQuery<UIPlatformStats>({
queryKey: [QUERY_KEYS.PLATFORM_STATS],
queryFn: async () => {
const { sorobanService } = await loadSoroban();
const [stats, velocity] = await Promise.all([
sorobanService.getPlatformStats(),
sorobanService.getCreditVelocity(24)
Expand Down Expand Up @@ -599,7 +602,7 @@ export function usePlatformStats(initialData?: UIPlatformStats) {
export function useRpcHealth() {
const query = useQuery({
queryKey: ['rpcHealth'],
queryFn: () => rpcServer.getHealth(),
queryFn: async () => (await loadSoroban()).rpcServer.getHealth(),
staleTime: 15000,
gcTime: 5 * 60 * 1000,
refetchInterval: 30000,
Expand Down
Loading