From 1d542ca7235728b6dc0559278060805b16a1381d Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 30 Jul 2026 07:35:39 +0100 Subject: [PATCH 1/2] fix: persist create-stream form draft to sessionStorage across page reloads --- .../__tests__/create-stream-content.test.tsx | 242 ++++++++++++++++++ .../streams/create/create-stream-content.tsx | 148 ++++++++++- 2 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx diff --git a/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx new file mode 100644 index 00000000..543cbadd --- /dev/null +++ b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import React from "react"; + +// ─── Session storage mock ─────────────────────────────────────────────────── +const sessionStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + get length() { + return Object.keys(store).length; + }, + }; +})(); + +vi.stubGlobal("sessionStorage", sessionStorageMock); +if (typeof window !== "undefined") { + Object.defineProperty(window, "sessionStorage", { + value: sessionStorageMock, + configurable: true, + writable: true, + }); +} + +const searchParamsMock = { get: vi.fn() }; +const push = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => searchParamsMock, +})); + +vi.mock("react-hot-toast", () => ({ + toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ status: "disconnected", session: null }), +})); + +vi.mock("@/lib/soroban", () => ({ + createStream: vi.fn(), + toBaseUnits: vi.fn(), + toDurationSeconds: vi.fn(), + getTokenAddress: vi.fn(), + toSorobanErrorMessage: vi.fn(), + TOKEN_ADDRESSES: { XLM: "xlm-address", USDC: "usdc-address" }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +import CreateStreamContent from "../create-stream-content"; + +function getRecipientInput() { + return screen.getByLabelText(/recipient/i) as HTMLInputElement; +} + +function getAmountInput() { + return screen.getByLabelText(/total amount/i) as HTMLInputElement; +} + +function getDurationInput() { + return screen.getByLabelText(/duration/i) as HTMLInputElement; +} + +describe("CreateStreamContent sessionStorage draft persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorageMock.clear(); + searchParamsMock.get.mockReturnValue(null); + }); + + it("saves form data to sessionStorage when fields change", async () => { + render(); + + const recipientInput = getRecipientInput(); + const amountInput = getAmountInput(); + + await act(async () => { + fireEvent.change(recipientInput, { + target: { value: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" }, + }); + }); + + await act(async () => { + fireEvent.change(amountInput, { target: { value: "100" } }); + }); + + // Wait for debounced save + await waitFor( + () => { + expect(sessionStorageMock.setItem).toHaveBeenCalled(); + }, + { timeout: 1000 } + ); + + const lastCallArgs = + sessionStorageMock.setItem.mock.calls[ + sessionStorageMock.setItem.mock.calls.length - 1 + ]; + expect(lastCallArgs[0]).toBe("flowfi.create-stream.draft.v1"); + + const savedData = JSON.parse(lastCallArgs[1]); + expect(savedData.recipient).toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + expect(savedData.amount).toBe("100"); + expect(savedData.token).toBe("XLM"); + expect(typeof savedData.savedAt).toBe("number"); + }); + + it("restores draft from sessionStorage on mount", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "USDC", + amount: "250", + duration: "60", + savedAt: Date.now() - 30000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(getRecipientInput().value).toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + }); + + expect(getAmountInput().value).toBe("250"); + expect(getDurationInput().value).toBe("60"); + + // Should show the resume draft banner + expect(screen.getByText(/resumed a saved draft/i)).toBeInTheDocument(); + }); + + it("shows resume draft banner when draft is restored", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "XLM", + amount: "50", + duration: "30", + savedAt: Date.now() - 60000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(screen.getByText(/resumed a saved draft/i)).toBeInTheDocument(); + }); + + expect(screen.getByText(/discard draft/i)).toBeInTheDocument(); + }); + + it("clears form and removes draft when 'Discard Draft' is clicked", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "XLM", + amount: "50", + duration: "30", + savedAt: Date.now() - 60000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(screen.getByText(/discard draft/i)).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText(/discard draft/i)); + }); + + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith( + "flowfi.create-stream.draft.v1" + ); + + // The banner should be gone + expect(screen.queryByText(/resumed a saved draft/i)).not.toBeInTheDocument(); + }); + + it("does not prefill from query params when a draft is restored", async () => { + const draft = { + recipient: "GDRAFT_RESTORED_ADDRESS_12345678901234567890123456789012345678901", + token: "XLM", + amount: "10", + duration: "30", + savedAt: Date.now() - 30000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + searchParamsMock.get.mockImplementation( + (key: string) => + key === "recipient" + ? "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + : null + ); + + render(); + + await waitFor(() => { + expect(getRecipientInput().value).toBe( + "GDRAFT_RESTORED_ADDRESS_12345678901234567890123456789012345678901" + ); + }); + + // The draft value should take precedence, not the query param + expect(getRecipientInput().value).not.toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + }); + + it("does not show draft banner if no draft exists", () => { + sessionStorageMock.getItem.mockReturnValue(null); + + render(); + + expect(screen.queryByText(/resumed a saved draft/i)).not.toBeInTheDocument(); + }); + + it("shows default empty form when no draft exists", () => { + sessionStorageMock.getItem.mockReturnValue(null); + + render(); + + expect(getRecipientInput().value).toBe(""); + expect(getAmountInput().value).toBe(""); + expect(getDurationInput().value).toBe("30"); + }); +}); diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index 2ddfef17..f367aa94 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { logger } from "@/lib/logger"; import { createStream, @@ -14,10 +14,52 @@ import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; import { toast } from "react-hot-toast"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, FileText, X } from "lucide-react"; import { useWallet } from "@/context/wallet-context"; const TOKEN_DECIMALS = 7; +const DRAFT_STORAGE_KEY = "flowfi.create-stream.draft.v1"; + +interface StreamDraft { + recipient: string; + token: string; + amount: string; + duration: string; + savedAt: number; +} + +function saveDraftToSession(data: StreamDraft): void { + try { + if (typeof window === "undefined") return; + sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data)); + } catch { + // sessionStorage may be full or unavailable + } +} + +function loadDraftFromSession(): StreamDraft | null { + try { + if (typeof window === "undefined") return null; + const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StreamDraft; + if (parsed && typeof parsed.recipient === "string") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearDraft(): void { + try { + if (typeof window === "undefined") return; + sessionStorage.removeItem(DRAFT_STORAGE_KEY); + } catch { + // ignore + } +} export default function CreateStreamContent() { const { status, session } = useWallet(); @@ -26,16 +68,59 @@ export default function CreateStreamContent() { const [nowTimestamp] = useState(() => Date.now()); const [loading, setLoading] = useState(false); const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle"); - const [formData, setFormData] = useState({ - recipient: "", - token: "XLM", - amount: "", - duration: "30", + const [draftRestored, setDraftRestored] = useState(false); + const [dismissedDraftBanner, setDismissedDraftBanner] = useState(false); + + const [formData, setFormData] = useState<{ + recipient: string; + token: string; + amount: string; + duration: string; + }>(() => { + // Restore draft from sessionStorage on initial mount + const draft = loadDraftFromSession(); + if (draft) { + return { + recipient: draft.recipient, + token: draft.token, + amount: draft.amount, + duration: draft.duration, + }; + } + return { + recipient: "", + token: "XLM", + amount: "", + duration: "30", + }; }); + // Set flag if a draft was restored + useEffect(() => { + const draft = loadDraftFromSession(); + if (draft) { + setDraftRestored(true); + } + }, []); + + // Persist form data to sessionStorage whenever it changes + useEffect(() => { + const timer = setTimeout(() => { + saveDraftToSession({ + recipient: formData.recipient, + token: formData.token, + amount: formData.amount, + duration: formData.duration, + savedAt: Date.now(), + }); + }, 500); // debounce writes + return () => clearTimeout(timer); + }, [formData]); + + // Handle recipient prefill from search params — but only if no draft is restored useEffect(() => { const recipientParam = searchParams.get("recipient"); - if (!recipientParam) return; + if (!recipientParam || draftRestored) return; import("@stellar/stellar-sdk").then(({ StrKey }) => { if (StrKey.isValidEd25519PublicKey(recipientParam)) { @@ -44,7 +129,7 @@ export default function CreateStreamContent() { logger.warn("Ignoring malformed recipient query param", { recipientParam }); } }); - }, [searchParams]); + }, [searchParams, draftRestored]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -76,6 +161,7 @@ export default function CreateStreamContent() { if (result.success) { setTxState("confirming"); + clearDraft(); // Clear draft on successful submission toast.success("Stream created successfully!"); setTimeout(() => { router.push("/dashboard"); @@ -104,15 +190,53 @@ export default function CreateStreamContent() { ? validateAmountInput(formData.amount, TOKEN_DECIMALS) : null; + const handleDismissDraft = useCallback(() => { + clearDraft(); + setDraftRestored(false); + setDismissedDraftBanner(true); + setFormData({ + recipient: "", + token: "XLM", + amount: "", + duration: "30", + }); + }, []); + + const handleBack = useCallback(() => { + // Navigate back without clearing the draft so it persists across navigation + router.push("/dashboard"); + }, [router]); + return (
- Back to Dashboard - + + + {/* Resume draft banner */} + {draftRestored && !dismissedDraftBanner && ( +
+ + + Resumed a saved draft from{" "} + {new Date( + loadDraftFromSession()?.savedAt ?? Date.now() + ).toLocaleTimeString()} + . You can continue editing or start fresh. + + +
+ )}

Create New Stream

From eedb131101691b4fd66d931af2169f3c5826b30c Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Wed, 5 Aug 2026 13:15:59 +0100 Subject: [PATCH 2/2] fix: address review feedback on session-storage draft handling --- .../__tests__/create-stream-content.test.tsx | 33 ++++++ .../streams/create/create-stream-content.tsx | 103 +++++++++--------- 2 files changed, 87 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx index 543cbadd..2ba414e7 100644 --- a/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx +++ b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx @@ -120,6 +120,39 @@ describe("CreateStreamContent sessionStorage draft persistence", () => { expect(typeof savedData.savedAt).toBe("number"); }); + it("does not write an empty draft for a pristine form", async () => { + sessionStorageMock.getItem.mockReturnValue(null); + + render(); + + // Wait well past the debounce window + await new Promise((resolve) => setTimeout(resolve, 700)); + + expect(sessionStorageMock.setItem).not.toHaveBeenCalled(); + }); + + it("ignores an empty draft on load", () => { + sessionStorageMock.getItem.mockReturnValue( + JSON.stringify({ + recipient: "", + token: "XLM", + amount: "", + duration: "30", + savedAt: Date.now() - 60000, + }) + ); + + render(); + + // No bogus "resumed draft" banner over a blank form + expect( + screen.queryByText(/resumed a saved draft/i) + ).not.toBeInTheDocument(); + expect(getRecipientInput().value).toBe(""); + expect(getAmountInput().value).toBe(""); + expect(getDurationInput().value).toBe("30"); + }); + it("restores draft from sessionStorage on mount", async () => { const draft = { recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index f367aa94..eb61a0a3 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -28,6 +28,29 @@ interface StreamDraft { savedAt: number; } +interface FormFields { + recipient: string; + token: string; + amount: string; + duration: string; +} + +const DEFAULT_FORM: FormFields = { + recipient: "", + token: "XLM", + amount: "", + duration: "30", +}; + +function isPristineForm(form: FormFields): boolean { + return ( + form.recipient === "" && + form.token === DEFAULT_FORM.token && + form.amount === "" && + form.duration === DEFAULT_FORM.duration + ); +} + function saveDraftToSession(data: StreamDraft): void { try { if (typeof window === "undefined") return; @@ -43,7 +66,14 @@ function loadDraftFromSession(): StreamDraft | null { const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as StreamDraft; - if (parsed && typeof parsed.recipient === "string") { + // Reject empty drafts so a bogus "resumed draft" banner is never shown + // over a blank form. + if ( + parsed && + typeof parsed.recipient === "string" && + typeof parsed.amount === "string" && + (parsed.recipient.trim() !== "" || parsed.amount.trim() !== "") + ) { return parsed; } return null; @@ -68,44 +98,31 @@ export default function CreateStreamContent() { const [nowTimestamp] = useState(() => Date.now()); const [loading, setLoading] = useState(false); const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle"); - const [draftRestored, setDraftRestored] = useState(false); + // Read the draft once at mount so the banner has a stable savedAt value + // instead of re-reading sessionStorage on every render. + const [restoredDraft, setRestoredDraft] = useState( + () => loadDraftFromSession() + ); const [dismissedDraftBanner, setDismissedDraftBanner] = useState(false); + const draftRestored = restoredDraft !== null; - const [formData, setFormData] = useState<{ - recipient: string; - token: string; - amount: string; - duration: string; - }>(() => { - // Restore draft from sessionStorage on initial mount - const draft = loadDraftFromSession(); - if (draft) { + const [formData, setFormData] = useState(() => { + if (restoredDraft) { return { - recipient: draft.recipient, - token: draft.token, - amount: draft.amount, - duration: draft.duration, + recipient: restoredDraft.recipient, + token: restoredDraft.token, + amount: restoredDraft.amount, + duration: restoredDraft.duration, }; } - return { - recipient: "", - token: "XLM", - amount: "", - duration: "30", - }; + return { ...DEFAULT_FORM }; }); - // Set flag if a draft was restored - useEffect(() => { - const draft = loadDraftFromSession(); - if (draft) { - setDraftRestored(true); - } - }, []); - - // Persist form data to sessionStorage whenever it changes + // Persist form data to sessionStorage whenever it changes — but never + // write an empty draft for a pristine form on first mount. useEffect(() => { const timer = setTimeout(() => { + if (isPristineForm(formData)) return; saveDraftToSession({ recipient: formData.recipient, token: formData.token, @@ -192,40 +209,28 @@ export default function CreateStreamContent() { const handleDismissDraft = useCallback(() => { clearDraft(); - setDraftRestored(false); + setRestoredDraft(null); setDismissedDraftBanner(true); - setFormData({ - recipient: "", - token: "XLM", - amount: "", - duration: "30", - }); + setFormData({ ...DEFAULT_FORM }); }, []); - const handleBack = useCallback(() => { - // Navigate back without clearing the draft so it persists across navigation - router.push("/dashboard"); - }, [router]); - return (
- + {/* Resume draft banner */} - {draftRestored && !dismissedDraftBanner && ( + {restoredDraft && !dismissedDraftBanner && (
Resumed a saved draft from{" "} - {new Date( - loadDraftFromSession()?.savedAt ?? Date.now() - ).toLocaleTimeString()} + {new Date(restoredDraft.savedAt).toLocaleTimeString()} . You can continue editing or start fresh.