Skip to content

Commit a11bda5

Browse files
authored
Merge pull request #166 from dinahmaccodes/issueb
fix: prevent race conditions and add retry for fee estimator
2 parents 9ff22cd + a65f767 commit a11bda5

11 files changed

Lines changed: 2836 additions & 7349 deletions

package-lock.json

Lines changed: 2537 additions & 7263 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"LICENSE"
4141
],
4242
"scripts": {
43-
"dev": "vite",
43+
"dev": "vite",
4444
"build": "npm run build:lib",
4545
"build:lib": "vite build --config vite.lib.config.ts",
4646
"build:app": "tsc -b && vite build",
@@ -97,7 +97,7 @@
9797
"vite-plugin-dts": "^5.0.3",
9898
"vitest": "^3.0.5"
9999
},
100-
"size-limit": [
100+
"size-limit": [
101101
{
102102
"name": "ES module (gzip)",
103103
"path": "dist/sorokit-ui.es.js",
@@ -110,5 +110,13 @@
110110
"limit": "70 KB",
111111
"gzip": true
112112
}
113-
]
113+
],
114+
"peerDependencies": {
115+
"tailwindcss": "^4.0.0"
116+
},
117+
"peerDependenciesMeta": {
118+
"tailwindcss": {
119+
"optional": true
120+
}
121+
}
114122
}

src/components/AssetBadge.test.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,35 @@ describe("AssetBadge", () => {
7070
expect(document.querySelector("[data-address]")).not.toBeInTheDocument();
7171
});
7272

73-
it("falls back to grey/surface-2 for an unknown asset", () => {
73+
it("applies deterministic color classes for an unknown asset", () => {
7474
const { container } = render(<AssetBadge balance={unknownBalance} />);
75-
const icon = container.querySelector(".bg-surface-2");
75+
// WAVEX (hash % 10 = 5) maps to purple background/text
76+
const icon = container.querySelector(".text-purple");
7677
expect(icon).toBeInTheDocument();
7778
});
7879

7980
it("renders the asset code for an unknown asset", () => {
8081
render(<AssetBadge balance={unknownBalance} />);
8182
expect(screen.getByText("WAVEX")).toBeInTheDocument();
8283
});
84+
85+
it("renders 1-character asset codes centered in the icon circle", () => {
86+
const oneCharBalance: Balance = {
87+
assetType: "credit_alphanum4",
88+
assetCode: "A",
89+
assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
90+
balance: "100",
91+
balanceFloat: 100,
92+
};
93+
const { container } = render(<AssetBadge balance={oneCharBalance} />);
94+
const icon = container.querySelector(".rounded-full");
95+
expect(icon).toHaveClass("flex");
96+
expect(icon).toHaveClass("items-center");
97+
expect(icon).toHaveClass("justify-center");
98+
expect(icon).toHaveClass("text-center");
99+
expect(icon).toHaveClass("leading-none");
100+
expect(icon?.textContent).toBe("A");
101+
});
83102
});
84103

85104
describe("AssetPill", () => {
@@ -98,11 +117,10 @@ describe("AssetPill", () => {
98117
expect(screen.getByText("USDC")).toHaveClass("text-brand");
99118
});
100119

101-
it("falls back to grey for an unknown asset code", () => {
120+
it("applies deterministic color for an unknown asset code", () => {
102121
render(<AssetPill assetCode="WAVEX" />);
103122
const pill = screen.getByText("WAVEX");
104-
expect(pill).toHaveClass("bg-surface-2");
105-
expect(pill).toHaveClass("text-ink-2");
123+
expect(pill).toHaveClass("text-purple");
106124
});
107125

108126
it("merges a custom className", () => {

src/components/AssetBadge.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@ import type { Balance } from "@/lib/client";
22
import { cn } from "@/lib/utils";
33
import { truncateAddress } from "@/lib/utils";
44

5-
const ASSET_COLORS: Record<string, { bg: string; text: string }> = {
6-
XLM: { bg: "bg-[rgba(20,184,166,0.12)]", text: "text-teal" },
7-
USDC: { bg: "bg-[rgba(86,69,212,0.12)]", text: "text-brand" },
8-
USDT: { bg: "bg-success-dim-strong", text: "text-green" },
9-
BTC: { bg: "bg-[rgba(249,115,22,0.12)]", text: "text-orange" },
10-
ETH: { bg: "bg-[rgba(168,85,247,0.12)]", text: "text-purple" },
11-
};
5+
const PALETTE = [
6+
{ bg: "bg-success-dim-strong", text: "text-green" }, // 0: USDT (hash % 10 = 0)
7+
{ bg: "bg-[rgba(20,184,166,0.12)]", text: "text-teal" }, // 1: XLM (hash % 10 = 1)
8+
{ bg: "bg-error-dim", text: "text-red" }, // 2: Red
9+
{ bg: "bg-[rgba(86,69,212,0.12)]", text: "text-brand" }, // 3: USDC (hash % 10 = 3)
10+
{ bg: "bg-[rgba(236,72,153,0.12)]", text: "text-[rgb(236,72,153)]" }, // 4: Pink
11+
{ bg: "bg-[rgba(168,85,247,0.12)]", text: "text-purple" }, // 5: ETH / WAVEX (hash % 10 = 5)
12+
{ bg: "bg-[rgba(6,182,212,0.12)]", text: "text-[rgb(6,182,212)]" }, // 6: Cyan
13+
{ bg: "bg-[rgba(249,115,22,0.12)]", text: "text-orange" }, // 7: BTC (hash % 10 = 7)
14+
{ bg: "bg-[rgba(234,179,8,0.12)]", text: "text-[rgb(234,179,8)]" }, // 8: Yellow
15+
{ bg: "bg-[rgba(99,102,241,0.12)]", text: "text-[rgb(99,102,241)]" }, // 9: Indigo
16+
];
1217

1318
function getAssetColor(code: string) {
14-
return ASSET_COLORS[code] ?? { bg: "bg-surface-2", text: "text-ink-2" };
19+
let hash = 0;
20+
for (let i = 0; i < code.length; i++) {
21+
hash = code.charCodeAt(i) + ((hash << 5) - hash);
22+
}
23+
const index = Math.abs(hash) % 10;
24+
return PALETTE[index];
1525
}
1626

1727
interface AssetBadgeProps {
@@ -53,6 +63,7 @@ export function AssetBadge({
5363
className={cn(
5464
"rounded-full flex items-center justify-center font-bold shrink-0",
5565
iconSize,
66+
"text-center leading-none",
5667
bg,
5768
text,
5869
)}

src/components/FeeEstimator.test.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,40 @@ describe("FeeEstimator", () => {
4747
expect(screen.getByText("Recommended")).toBeInTheDocument();
4848
});
4949

50-
it("renders the error message when the client returns an error", async () => {
51-
mockEstimateFee({ data: null, error: "Rate limit exceeded" });
50+
it("renders the error message and a retry button when the client returns an error", async () => {
51+
let callCount = 0;
52+
vi.mocked(getClient).mockReturnValue({
53+
transaction: {
54+
estimateFee: vi.fn().mockImplementation(() => {
55+
callCount++;
56+
if (callCount === 1) {
57+
return Promise.resolve({ data: null, error: "Rate limit exceeded" });
58+
} else {
59+
return Promise.resolve({ data: { baseFee: "150", recommended: "600" }, error: null });
60+
}
61+
}),
62+
},
63+
} as unknown as SorokitClient);
64+
5265
render(<FeeEstimator />);
5366

67+
// Initial check for error state
5468
await waitFor(() => {
5569
expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument();
5670
});
71+
72+
const retryButton = screen.getByRole("button", { name: "Retry" });
73+
expect(retryButton).toBeInTheDocument();
74+
75+
// Click retry
76+
fireEvent.click(retryButton);
77+
78+
// Should resolve with new data, clearing the error
79+
await waitFor(() => {
80+
expect(screen.getByText("150")).toBeInTheDocument();
81+
expect(screen.getByText("600")).toBeInTheDocument();
82+
});
83+
expect(screen.queryByText("Rate limit exceeded")).not.toBeInTheDocument();
5784
});
5885

5986
it("clicking the refresh button triggers a new estimateFee call", async () => {

src/components/FeeEstimator.tsx

Lines changed: 106 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
1-
import { Refresh01Icon } from "@hugeicons/core-free-icons";
2-
import { HugeiconsIcon } from "@hugeicons/react";
3-
import { useCallback, useEffect, useState } from "react";
4-
1+
import { useEffect, useState, useRef, useCallback } from "react";
52
import { getClient } from "@/lib/client";
63
import { cn } from "@/lib/utils";
4+
import { HugeiconsIcon } from "@hugeicons/react";
5+
import { Refresh01Icon } from "@hugeicons/core-free-icons";
76

8-
interface FeeData {
9-
baseFee: string;
10-
recommended: string;
11-
}
12-
13-
interface FeeEstimatorProps {
7+
export interface FeeEstimatorProps {
8+
operations?: number;
9+
network?: "testnet" | "public";
10+
onEstimate?: (fee: string) => void;
1411
className?: string;
15-
/** Auto-refresh interval in ms. 0 = no refresh. */
1612
refreshInterval?: number;
1713
}
1814

1915
export function FeeEstimator({
16+
operations = 1,
17+
network: _network = "testnet",
18+
onEstimate,
2019
className,
21-
refreshInterval = 0,
20+
refreshInterval = 10000,
2221
}: FeeEstimatorProps) {
23-
const [fee, setFee] = useState<FeeData | null>(null);
24-
const [loading, setLoading] = useState(true);
22+
const [fee, setFee] = useState<{ baseFee: string; recommended: string } | null>(null);
23+
const [loading, setLoading] = useState(false);
2524
const [error, setError] = useState<string | null>(null);
25+
const [retryTrigger, setRetryTrigger] = useState(0);
26+
27+
const loadingRef = useRef(false);
28+
const onEstimateRef = useRef(onEstimate);
29+
30+
// Update ref without triggering re-render
31+
useEffect(() => {
32+
onEstimateRef.current = onEstimate;
33+
});
2634

2735
const load = useCallback(async () => {
36+
if (loadingRef.current) return;
37+
loadingRef.current = true;
2838
setLoading(true);
39+
setError(null);
2940
try {
3041
const { data, error: err } = await getClient().transaction.estimateFee();
3142
if (err) {
@@ -34,33 +45,62 @@ export function FeeEstimator({
3445
}
3546
setFee(data);
3647
setError(null);
48+
} catch (e) {
49+
setError(e instanceof Error ? e.message : "Failed to load fee estimate");
3750
} finally {
3851
setLoading(false);
52+
loadingRef.current = false;
3953
}
4054
}, []);
4155

56+
// Initial load and retry trigger
4257
useEffect(() => {
43-
const timerId = window.setTimeout(() => {
58+
let cancelled = false;
59+
const doLoad = async () => {
60+
if (!cancelled) {
61+
await load();
62+
}
63+
};
64+
void doLoad();
65+
return () => {
66+
cancelled = true;
67+
};
68+
}, [load, retryTrigger]);
69+
70+
// Interval manager
71+
useEffect(() => {
72+
if (refreshInterval <= 0 || error) return;
73+
74+
const intervalId = setInterval(() => {
4475
void load();
45-
}, 0);
46-
if (refreshInterval > 0) {
47-
const id = setInterval(() => {
48-
void load();
49-
}, refreshInterval);
50-
return () => {
51-
window.clearTimeout(timerId);
52-
clearInterval(id);
53-
};
54-
}
76+
}, refreshInterval);
77+
5578
return () => {
56-
window.clearTimeout(timerId);
79+
clearInterval(intervalId);
5780
};
58-
}, [load, refreshInterval]);
81+
}, [load, refreshInterval, error]);
82+
83+
// Handle onEstimate callback when recommended fee or operations changes
84+
useEffect(() => {
85+
if (fee?.recommended) {
86+
const displayRecommended = (parseInt(fee.recommended) * operations).toString();
87+
onEstimateRef.current?.(displayRecommended);
88+
}
89+
}, [fee?.recommended, operations]);
90+
91+
// Trigger manually or via retry
92+
const handleRetry = useCallback(() => {
93+
setRetryTrigger((prev) => prev + 1);
94+
}, []);
95+
96+
// Calculate fees to display
97+
const displayBaseFee = fee ? (parseInt(fee.baseFee) * operations).toString() : "";
98+
const displayRecommended = fee ? (parseInt(fee.recommended) * operations).toString() : "";
5999

60100
return (
61101
<div
62102
className={cn(
63-
"rounded-xl border border-line bg-surface overflow-hidden",
103+
"rounded-xl border border-line bg-surface overflow-hidden relative",
64104
className,
65105
)}
66106
>
@@ -72,11 +112,11 @@ export function FeeEstimator({
72112
</p>
73113
</div>
74114
<button
75-
onClick={() => void load()}
115+
onClick={handleRetry}
76116
disabled={loading}
117+
aria-label="Refresh fee estimate"
77118
className="p-1.5 rounded-lg hover:bg-surface-2 text-ink-3 hover:text-ink-2 transition-colors disabled:opacity-40"
78119
title="Refresh"
79-
aria-label="Refresh fee estimate"
80120
>
81121
<HugeiconsIcon
82122
icon={Refresh01Icon}
@@ -88,21 +128,40 @@ export function FeeEstimator({
88128
</button>
89129
</div>
90130

91-
<div className="px-5 py-4" aria-live="polite" aria-atomic="true">
92-
{loading && !fee ? (
93-
<div className="flex gap-4">
94-
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
95-
<div className="h-8 w-24 rounded-lg bg-surface-2 animate-pulse" />
131+
<div className="px-5 py-4 min-h-[64px] flex items-center">
132+
{/* Polite live region for screen readers to announce fee updates */}
133+
<div aria-live="polite" aria-atomic="true" className="sr-only">
134+
{fee ? `Base Fee: ${displayBaseFee} stroops, Recommended: ${displayRecommended} stroops` : ""}
135+
</div>
136+
137+
{loading && !fee && !error ? (
138+
<div className="flex gap-4 animate-pulse w-full">
139+
<div className="h-8 w-24 rounded-lg bg-surface-2" />
140+
<div className="h-8 w-24 rounded-lg bg-surface-2" />
96141
</div>
97142
) : error ? (
98-
<p className="text-[12px] text-red">{error}</p>
143+
<div className="flex flex-col gap-2 items-start w-full">
144+
<p className="text-[12px] text-red">{error}</p>
145+
<button
146+
type="button"
147+
onClick={handleRetry}
148+
className="px-3 py-1.5 text-[11px] font-medium text-white bg-brand rounded-lg hover:bg-brand-hover transition-colors"
149+
>
150+
Retry
151+
</button>
152+
</div>
99153
) : fee ? (
100-
<div className="flex items-center gap-4">
101-
<FeeCell label="Base Fee" value={fee.baseFee} unit="stroops" />
154+
<div className="flex items-center gap-4 relative w-full">
155+
{loading && (
156+
<div className="absolute inset-0 flex items-center justify-center bg-surface/50 backdrop-blur-[0.5px]">
157+
<span className="w-4 h-4 border border-current border-t-transparent rounded-full animate-spin text-brand" />
158+
</div>
159+
)}
160+
<FeeCell label="Base Fee" value={displayBaseFee} unit="stroops" />
102161
<div className="w-px h-8 bg-line" />
103162
<FeeCell
104163
label="Recommended"
105-
value={fee.recommended}
164+
value={displayRecommended}
106165
unit="stroops"
107166
highlight
108167
/>
@@ -113,17 +172,19 @@ export function FeeEstimator({
113172
);
114173
}
115174

175+
interface FeeCellProps {
176+
label: string;
177+
value: string;
178+
unit: string;
179+
highlight?: boolean;
180+
}
181+
116182
function FeeCell({
117183
label,
118184
value,
119185
unit,
120186
highlight,
121-
}: {
122-
label: string;
123-
value: string;
124-
unit: string;
125-
highlight?: boolean;
126-
}) {
187+
}: FeeCellProps) {
127188
return (
128189
<div className="flex flex-col gap-1">
129190
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-ink-4">

0 commit comments

Comments
 (0)