diff --git a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx
new file mode 100644
index 00000000..2818fea3
--- /dev/null
+++ b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx
@@ -0,0 +1,185 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+
+// ─── Mocks ──────────────────────────────────────────────────────────────
+
+const mockSession = {
+ publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7",
+ network: "TESTNET",
+ walletName: "Freighter",
+};
+
+vi.mock("@/context/wallet-context", () => ({
+ useWallet: () => ({
+ session: mockSession,
+ isHydrated: true,
+ }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock("@/lib/api/_shared", () => ({
+ getApiBaseUrl: () => "http://localhost:4000",
+}));
+
+vi.mock("@/lib/logger", () => ({
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+vi.mock("@/hooks/useStreamEvents", () => ({
+ useStreamEvents: () => ({ events: [] }),
+}));
+
+vi.mock("@/lib/soroban", () => ({
+ withdrawFromStream: vi.fn(),
+ cancelStream: vi.fn(),
+ topUpStream: vi.fn(),
+ pauseStream: vi.fn(),
+ resumeStream: vi.fn(),
+ toBaseUnits: vi.fn((v: string) => BigInt(v)),
+ toSorobanErrorMessage: vi.fn((e) => String(e)),
+}));
+
+vi.mock("@/components/stream-creation/CancelConfirmModal", () => ({
+ CancelConfirmModal: () =>
Cancel Modal
,
+}));
+
+vi.mock("@/components/ui/Button", () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ ...rest
+ }: React.ButtonHTMLAttributes & { glow?: boolean; variant?: string; children?: React.ReactNode }) => (
+
+ ),
+}));
+
+import StreamDetailsContent from "../stream-details-content";
+
+const STREAM_ID = "42";
+
+function createMockStream() {
+ return {
+ id: "stream-42",
+ streamId: 42,
+ sender: "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD",
+ recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7",
+ tokenAddress: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN",
+ depositedAmount: "1000000000",
+ withdrawnAmount: "250000000",
+ ratePerSecond: "100",
+ startTime: Math.floor(Date.now() / 1000) - 86400,
+ endTime: Math.floor(Date.now() / 1000) + 86400,
+ lastUpdateTime: Math.floor(Date.now() / 1000),
+ isActive: true,
+ status: "Active",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+describe("StreamDetailsContent loading skeleton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it("renders a distinct loading skeleton with shimmer placeholders while fetch is in-flight", () => {
+ // Keep fetch pending indefinitely
+ vi.mocked(global.fetch).mockImplementation(
+ () => new Promise(() => {}) // never resolves
+ );
+
+ render();
+
+ // Should show skeleton elements, not a simple spinner
+ const skeletonRegion = screen.getByRole("status");
+ expect(skeletonRegion).toBeInTheDocument();
+ expect(skeletonRegion).toHaveAttribute("aria-label", "Loading stream details");
+
+ // Should NOT show the not-found error state
+ expect(screen.queryByText(/stream not found/i)).not.toBeInTheDocument();
+ expect(screen.queryByTestId("cancel-modal")).not.toBeInTheDocument();
+ });
+
+ it("transitions from skeleton to stream content when data loads successfully", async () => {
+ const mockStream = createMockStream();
+
+ vi.mocked(global.fetch)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockStream,
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ events: [], total: 0 }),
+ } as Response);
+
+ render();
+
+ // Initially shows skeleton
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ // After data loads, the skeleton should be replaced by stream details
+ await waitFor(() => {
+ expect(screen.getByText(/stream details/i)).toBeInTheDocument();
+ });
+
+ // Skeleton should be gone
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+
+ // Stream-specific content should be visible
+ expect(screen.getByText(/stream #42/i)).toBeInTheDocument();
+ });
+
+ it("transitions from skeleton to not-found state when stream is confirmed missing", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Stream not found" }),
+ } as Response);
+
+ render();
+
+ // Initially shows skeleton
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ // After fetch resolves with error, should show the not-found/error state
+ await waitFor(() => {
+ expect(screen.getByText(/stream not found/i)).toBeInTheDocument();
+ });
+
+ // Skeleton should be gone
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+
+ // The not-found state should have a back-to-dashboard link
+ expect(screen.getByText(/← back to dashboard/i)).toBeInTheDocument();
+ });
+
+ it("shows 'stream not found' when API returns non-ok response", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "Stream not found" }),
+ } as Response);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/stream not found/i)).toBeInTheDocument();
+ });
+
+ // The not-found/error UI should have a back link
+ expect(screen.getByText(/← back to dashboard/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx
index 1c8cf265..74fbb890 100644
--- a/frontend/src/app/streams/[id]/stream-details-content.tsx
+++ b/frontend/src/app/streams/[id]/stream-details-content.tsx
@@ -290,10 +290,65 @@ export default function StreamDetailsContent({ streamId }: { streamId: string })
if (loading) {
return (
-
-
-
-
Loading stream details...
+
+ {/* Skeleton: Header */}
+
+
+ {/* Skeleton: Stream Overview */}
+
+
+ {/* Skeleton: Financial Overview */}
+
+
+
+
+
+
+ {/* Skeleton: Progress */}
+
+
+ {/* Skeleton: Actions */}
+
+
+ {/* Skeleton: Event History */}
+
@@ -546,6 +601,32 @@ export default function StreamDetailsContent({ streamId }: { streamId: string })
);
}
+function SkeletonLine() {
+ return
;
+}
+
+function SkeletonCard({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function SkeletonEventRow() {
+ return (
+
+ );
+}
+
function StatusBadge({ status, isPaused }: { status: string; isPaused?: boolean }) {
const getStyles = () => {
if (isPaused) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30";