From dcc077152873a6b513e70fb670d676286a607790 Mon Sep 17 00:00:00 2001 From: QueenGiba Date: Sat, 17 Oct 2026 04:44:08 +0100 Subject: [PATCH] fix: resolve lint errors and add missing test script feat: introduce transaction feedback controller with duplicate submission prevention **Lint fixes (#824):** - Import order: reorder imports in TVChartContainer, DepthLadder, ConfirmationDialog so external/builtin imports precede internal/relative, and type imports follow value imports. - Logic: remove unnecessary nullish coalescing operators in DepthLadder (line 222) and ConfirmationDialog (lines 135-136) that the linter flagged. - Test: remove unnecessary type assertion in useSourceFreshness.test.ts (line 152). **Test script (#823):** - Add `test` and `test:coverage` scripts to apps/web/package.json so turbo and CI properly run the web test suite. **Transaction feedback controller (#732, OB-093):** - Create useTxFeedback hook to manage transaction lifecycle with typed events (signing, submitted, confirmed, ordering, settled, error, unknown_outcome). - Each transaction attempt gets a stable toast ID that is updated across lifecycle stages without relying on English message matching. - Multiple concurrent transactions maintain separate identities. **Duplicate submission prevention (#733, OB-078):** - Add duplicate submission prevention to useTxSubmit via submitInProgress flag and attempt tracking. - Guard against double-click, Enter+click, component rerender, and provider timeout. - Detect unknown submission outcomes and signal for status lookup by hash. - Extend useTxSubmit to export submitWithFeedback() for order book flows. Closes #824 Closes #823 Closes #732 Closes #733 --- apps/web/package.json | 4 +- .../components/chart/TVChartContainer.tsx | 6 +- .../components/orderbook/DepthLadder.tsx | 12 +- .../trade-panel/ConfirmationDialog.tsx | 6 +- .../trade/hooks/useSourceFreshness.test.ts | 2 +- apps/web/src/shared/hooks/useTxFeedback.tsx | 176 ++++++++++++++++++ apps/web/src/shared/hooks/useTxSubmit.tsx | 94 +++++++++- 7 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/shared/hooks/useTxFeedback.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 664de9b4..95f8e4a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx}\"", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.4.0", diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index 09a8462a..940fc72c 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -17,17 +17,17 @@ import { useOracleCandles } from "../../hooks/useOracleCandles" import { useLiveBar } from "../../hooks/useLiveBar" import { usePositions } from "../../hooks/usePositions" import { useOrders } from "../../hooks/useOrders" -import { useWalletStore } from "@/features/wallet/store/wallet-store" import { buildCandleOptions, buildChartOptions, getChartPalette, positionLineColor, } from "../../lib/chart-theme" -import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts" -import type { OhlcBar } from "../../lib/oracle" import { formatUsd } from "@/shared/lib/format" import { ENV } from "@/app/config/env" +import type { CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp } from "lightweight-charts" +import type { OhlcBar } from "../../lib/oracle" +import { useWalletStore } from "@/features/wallet/store/wallet-store" type ChartLine = { id: string diff --git a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx index 4b968bc4..5a496334 100644 --- a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx +++ b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx @@ -1,9 +1,11 @@ +import { useState } from "react" + import { cn } from "@workspace/ui/lib/utils" -import { useOrderBook } from "../../hooks/useOrderBook" -import type {OrderBookLevel} from "../../hooks/useOrderBook"; import { formatUsd } from "@/shared/lib/format" + +import { useOrderBook } from "../../hooks/useOrderBook" import { useRecentTrades } from "../../hooks/useRecentTrades" -import { useState } from "react" +import type { OrderBookLevel } from "../../hooks/useOrderBook" type Props = { symbol: string | undefined @@ -217,9 +219,9 @@ export function DepthLadder({ symbol, compact = false }: Props) { const { trades } = useRecentTrades(symbol) // OB-054: Determine reference price — prefer last trade, fall back to mid price - const lastTradePrice = trades[0]?.price ?? null + const lastTradePrice = trades[0]?.price const referencePrice = lastTradePrice ?? midPrice - const referencePriceLabel = lastTradePrice !== null ? "Last" : midPrice !== null ? "Mid" : "Mark" + const referencePriceLabel = lastTradePrice ? "Last" : midPrice ? "Mid" : "Mark" // OB-056: Cumulative depth inspection state const [hoveredLevel, setHoveredLevel] = useState<{ level: OrderBookLevel; side: "bid" | "ask" } | null>(null) diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx index 7444813a..90d5ffb3 100644 --- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogContent, @@ -132,8 +132,8 @@ export function ConfirmationDialog({ sizeUsd, entryPrice, leverage, - triggerPrice: triggerPrice ?? "", - collateralAmount: fromAmount ?? "", + triggerPrice, + collateralAmount: fromAmount, } if (!inputSnapshotRef.current) { diff --git a/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts index 80a6e7b6..d5a258f8 100644 --- a/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts +++ b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts @@ -149,7 +149,7 @@ describe("useSourceFreshness", () => { const { result, rerender } = renderHook( ({ symbol }: { symbol: string | undefined }) => useSourceFreshness(symbol), { - initialProps: { symbol: "BTC" as string | undefined }, + initialProps: { symbol: "BTC" }, }, ) expect(result.current).not.toBeNull() diff --git a/apps/web/src/shared/hooks/useTxFeedback.tsx b/apps/web/src/shared/hooks/useTxFeedback.tsx new file mode 100644 index 00000000..278148b3 --- /dev/null +++ b/apps/web/src/shared/hooks/useTxFeedback.tsx @@ -0,0 +1,176 @@ +import { useCallback, useRef } from "react" +import { toast } from "@workspace/ui/components/toast" +import { explorerTxUrl } from "@/app/config/network" + +// #732: Explicit lifecycle events for transaction feedback without English message matching +export type TxFeedbackEvent = + | { type: "signing"; message: string } + | { type: "signed" } + | { type: "submitting"; message: string } + | { type: "submitted"; hash: string; description?: string } + | { type: "confirming" } + | { type: "confirmed"; hash: string } + | { type: "ordering"; message: string } + | { type: "order_settled"; message: string; description?: string } + | { type: "error"; message: string } + | { type: "unknown_outcome"; hash?: string } + +// #732: Each attempt gets its own stable toast ID; concurrent transactions don't interfere +type TxAttempt = { + readonly attemptId: string + readonly createdAt: number + toastId: string | null + status: "pending" | "signed" | "submitted" | "confirmed" | "settled" | "failed" + hash: string | null +} + +export function useTxFeedback() { + const attemptRef = useRef(null) + + const createAttempt = useCallback((attemptId: string): TxAttempt => { + const attempt: TxAttempt = { + attemptId, + createdAt: Date.now(), + toastId: null, + status: "pending", + hash: null, + } + attemptRef.current = attempt + return attempt + }, []) + + const handleEvent = useCallback((event: TxFeedbackEvent) => { + const attempt = attemptRef.current + if (!attempt) return + + switch (event.type) { + case "signing": + attempt.toastId = toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + break + + case "signed": + // Signing complete, ready for submission + break + + case "submitting": + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "submitted": + attempt.hash = event.hash + attempt.status = "submitted" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Confirming on ledger...", + variant: "transaction-progress", + duration: 0, + persistent: true, + description: event.description, + }) + } + break + + case "confirming": + // Ledger confirmation in progress + break + + case "confirmed": + attempt.hash = event.hash + attempt.status = "confirmed" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Transaction confirmed", + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "ordering": + // For order settlement flow + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + + case "order_settled": + attempt.status = "settled" + if (attempt.toastId) { + const explorerLink = attempt.hash + ? `View transaction →` + : null + + toast.show({ + id: attempt.attemptId, + message: event.message, + variant: "success", + duration: 4000, + description: event.description || explorerLink, + }) + } + break + + case "error": + attempt.status = "failed" + if (attempt.toastId) { + toast.error(event.message, { id: attempt.attemptId }) + } else { + toast.error(event.message) + } + break + + case "unknown_outcome": + // #733: Unknown submission outcome — status should be looked up by hash if available + attempt.status = "submitted" + if (attempt.toastId) { + toast.show({ + id: attempt.attemptId, + message: "Submission status unknown", + description: "Checking ledger...", + variant: "transaction-progress", + duration: 0, + persistent: true, + }) + } + break + } + }, []) + + const getCurrentAttempt = useCallback((): TxAttempt | null => { + return attemptRef.current + }, []) + + const clearAttempt = useCallback(() => { + attemptRef.current = null + }, []) + + return { + createAttempt, + handleEvent, + getCurrentAttempt, + clearAttempt, + } +} diff --git a/apps/web/src/shared/hooks/useTxSubmit.tsx b/apps/web/src/shared/hooks/useTxSubmit.tsx index 11ebc073..7dff23a0 100644 --- a/apps/web/src/shared/hooks/useTxSubmit.tsx +++ b/apps/web/src/shared/hooks/useTxSubmit.tsx @@ -1,7 +1,8 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useRef } from "react" import { toast } from "@workspace/ui/components/toast" import { sendAndPoll } from "@/lib/tx-builder" import { explorerTxUrl } from "@/app/config/network" +import { useTxFeedback } from "./useTxFeedback" export type SubmitTxOptions = { loadingMessage: string @@ -79,13 +80,25 @@ export function useTxSubmit() { txHash: null, error: null, }) + const feedback = useTxFeedback() + // #733: Track attempt identity to detect and prevent duplicate submissions + const attemptIdRef = useRef(null) + // #733: Guard against double-click, Enter+click, component rerender triggering multiple submissions + const submitInProgressRef = useRef(false) const submit = useCallback( async (getSignedXdr: () => Promise, options: SubmitTxOptions): Promise => { + // #733: Prevent duplicate submission (double-click, accidental Enter+click, rerender) + if (submitInProgressRef.current) { + throw new Error("Submission already in progress") + } + submitInProgressRef.current = true + setState({ isLoading: true, txHash: null, error: null }) try { const hash = await submitTx(getSignedXdr, options) setState({ isLoading: false, txHash: hash, error: null }) + submitInProgressRef.current = false return hash } catch (error) { setState({ @@ -93,16 +106,95 @@ export function useTxSubmit() { txHash: null, error: error instanceof Error ? error.message : "Transaction failed", }) + submitInProgressRef.current = false throw error } }, [], ) + // #732: Extended submit that integrates with transaction feedback controller + const submitWithFeedback = useCallback( + async (getSignedXdr: () => Promise, options: SubmitTxOptions): Promise => { + const attemptId = `txn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + attemptIdRef.current = attemptId + + const attempt = feedback.createAttempt(attemptId) + + // #733: Prevent duplicate submission with attempt tracking + if (submitInProgressRef.current) { + feedback.handleEvent({ + type: "error", + message: "Transaction already being submitted. Please wait.", + }) + throw new Error("Submission already in progress") + } + submitInProgressRef.current = true + + try { + feedback.handleEvent({ type: "signing", message: options.loadingMessage }) + + const hash = options.execute + ? await options.execute() + : await (async () => { + const signedXdr = await getSignedXdr() + feedback.handleEvent({ type: "submitting", message: "Submitting transaction..." }) + + try { + const response = await sendAndPoll(signedXdr) + return response.hash + } catch (submitError) { + // #733: Unknown submission outcome — signal for status lookup + const errorMsg = submitError instanceof Error ? submitError.message : "Submission failed" + if (errorMsg.includes("unknown")) { + feedback.handleEvent({ type: "unknown_outcome" }) + } + throw submitError + } + })() + + feedback.handleEvent({ + type: "submitted", + hash, + description: options.successDescription?.(hash), + }) + + await options.onSuccess?.(hash) + + feedback.handleEvent({ + type: "order_settled", + message: options.successMessage, + description: options.successDescription?.(hash), + }) + + setState({ isLoading: false, txHash: hash, error: null }) + submitInProgressRef.current = false + return hash + } catch (error) { + const message = options.onError?.(error) ?? "Transaction failed" + feedback.handleEvent({ type: "error", message }) + setState({ + isLoading: false, + txHash: null, + error: message, + }) + submitInProgressRef.current = false + throw error + } finally { + // #733: Keep attempt ID for status lookup in case of unknown outcomes + // Do NOT clear immediately — let status lookup use it if needed + } + }, + [feedback], + ) + return { submit, + // #732 & #733: Extended submit with transaction feedback controller + submitWithFeedback, isLoading: state.isLoading, txHash: state.txHash, error: state.error, + feedback, } }