From 56f325e81a820c0f9d70ea2cc94648e30c7086ad Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Fri, 4 Sep 2026 12:25:16 +0200 Subject: [PATCH] fix(widget): wait for session atoms before redirecting transaction flows Route guards treated an unset keep-alive session as missing and bounced users home. Replay the session stream on mount, show a skeleton while initial, and admit borrow completion from execution state instead of a lagging view. --- .../react/borrow-flow-route.tsx | 54 ++- .../state/atoms/borrow-flow-execution.ts | 22 + .../state/atoms/borrow-flow-session.ts | 12 +- .../state/atoms/borrow-flow.ts | 24 +- .../react/classic-flow-route.tsx | 37 +- .../state/atoms/classic-flow-session.ts | 12 +- .../state/atoms/classic-flow.ts | 25 +- .../ui/components/loading-skeleton/index.tsx | 7 + .../loading-skeleton.browser.test.tsx | 19 + .../tests/features/borrow-flow-atoms.test.ts | 11 +- .../borrow-flow-route-identity.dom.test.tsx | 16 +- .../tests/features/classic-flow-atoms.test.ts | 8 +- ...nsaction-flow-route-readiness.dom.test.tsx | 458 ++++++++++++++++++ .../tests/hooks/action-preview.dom.test.tsx | 19 +- 14 files changed, 625 insertions(+), 99 deletions(-) create mode 100644 packages/widget/src/shared/ui/components/loading-skeleton/index.tsx create mode 100644 packages/widget/tests/components/loading-skeleton.browser.test.tsx create mode 100644 packages/widget/tests/features/transaction-flow-route-readiness.dom.test.tsx diff --git a/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx b/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx index 51f9805d0..f5676553c 100644 --- a/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx +++ b/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx @@ -1,21 +1,26 @@ import { make as makeScopedAtom, useAtomValue } from "@effect/atom-react"; import { Schema } from "effect"; -import type * as Atom from "effect/unstable/reactivity/Atom"; -import { createContext, type PropsWithChildren, useContext } from "react"; +import { + createContext, + type PropsWithChildren, + useContext, + useState, +} from "react"; import { Navigate, Outlet, useParams } from "react-router"; import { type MarketId, MarketId as MarketIdSchema, } from "../../../domain/borrow/ids"; +import { LoadingSkeleton } from "../../../shared/ui/components/loading-skeleton"; import type { BorrowTransactionFlowEntry } from "../model/borrow-transaction-flow"; import { getBorrowTransactionFlowRoutes } from "../model/borrow-transaction-flow"; -import { currentBorrowFlowSessionAtom } from "../state/atoms/borrow-flow"; +import { makeBorrowFlowRouteSessionAtom } from "../state/atoms/borrow-flow"; import { type BorrowFlowExecutionFacade, type BorrowFlowReviewFacade, type BorrowFlowSessionFacade, type BorrowFlowSessionModule, - currentBorrowFlowSessionRootAtom, + borrowFlowSessionRootAtomFamily, makeBorrowFlowExecutionScope, makeBorrowFlowReviewScope, } from "../state/atoms/borrow-flow-session"; @@ -74,34 +79,27 @@ export const BorrowTransactionFlowRoute = ({ const marketId = routeParams.marketId ? Schema.decodeSync(MarketIdSchema)(routeParams.marketId) : undefined; - const session = useAtomValue(currentBorrowFlowSessionAtom); - if (session && matchesEntry(session.intake.entry, expected, marketId)) { - return ; - } + const [sessionAtom] = useState(makeBorrowFlowRouteSessionAtom); + const result = useAtomValue(sessionAtom); const fallbackPath = getEntryFallbackPath(expected, marketId); - return ; -}; - -const SessionBinding = ({ - entry, -}: { - readonly entry: BorrowTransactionFlowEntry; -}) => { - const rootAtom = useAtomValue(currentBorrowFlowSessionRootAtom); - if (!rootAtom) { + if (result._tag === "Initial") return ; + if (result._tag === "Failure") return ; + const session = result.value; + if (session && matchesEntry(session.intake.entry, expected, marketId)) { return ( - + ); } - return ; + return ; }; const MountedSessionBinding = ({ rootAtom, }: { - readonly rootAtom: NonNullable< - Atom.Type - >; + readonly rootAtom: ReturnType; }) => { const session = useAtomValue(rootAtom); return ( @@ -169,9 +167,17 @@ const ExecutionBinding = ({ children }: PropsWithChildren) => { export const BorrowTransactionFlowCompletionGuard = () => { const execution = useBorrowTransactionFlowExecution(); + const [completionAtom] = useState(() => execution.makeCompletionStateAtom()); + const result = useAtomValue(completionAtom); const view = useAtomValue(execution.viewAtom); const { stepsPath } = getBorrowTransactionFlowRoutes( useBorrowTransactionFlow().intake.entry ); - return view.isDone ? : ; + if (result._tag === "Initial") return ; + if (result._tag === "Failure" || !result.value) { + return ; + } + // Admission is authoritative; the page still needs its completion details. + if (!view.isDone) return ; + return ; }; diff --git a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-execution.ts b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-execution.ts index add561514..b63ddeab6 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-execution.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-execution.ts @@ -35,6 +35,27 @@ export const makeBorrowFlowExecutionScopeAtom = ( outcome._tag === "Acquired" ? outcome.execution.states : Stream.never, label: "borrowFlowExecutionScope", makeValue: ({ handleAtom, stateAtom }) => { + // Read the existing workflow afresh on completion-route mount. Do not + // reacquire its execution or consult the potentially lagging viewAtom. + const makeCompletionStateAtom = () => + walletRuntime + .atom((context) => + Stream.unwrap( + context + .result(handleAtom) + .pipe( + Effect.map((outcome) => + outcome._tag === "Acquired" + ? outcome.execution.states.pipe( + Stream.map((state) => state._tag === "Completed") + ) + : Stream.succeed(false) + ) + ) + ) + ) + .pipe(Atom.withLabel("borrowFlowCompletionState")); + const viewAtom = Atom.make((get) => { const result = get(stateAtom); const state = Option.getOrNull(AsyncResult.value(result)); @@ -96,6 +117,7 @@ export const makeBorrowFlowExecutionScopeAtom = ( facade: { backAtom, finishAtom, + makeCompletionStateAtom, viewAtom, workflowCommandAtom, }, diff --git a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-session.ts b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-session.ts index 4fc68cc25..c08a28d8c 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-session.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow-session.ts @@ -3,10 +3,7 @@ import * as Atom from "effect/unstable/reactivity/Atom"; import { makeScopedEffectAtom } from "../../../../app/runtime/scoped-effect-atom"; import { walletRuntime } from "../../../../app/runtime/wallet-runtime"; import type { BorrowFlowSession } from "../../model/borrow-transaction-flow"; -import { - borrowTransactionFlowServiceAtom, - currentBorrowFlowSessionAtom, -} from "./borrow-flow"; +import { borrowTransactionFlowServiceAtom } from "./borrow-flow"; import { makeBorrowFlowExecutionScopeAtom } from "./borrow-flow-execution"; import { makeBorrowFlowReviewScopeAtom } from "./borrow-flow-review"; @@ -49,11 +46,6 @@ type BorrowFlowExecutionModule = Atom.Type< >; export type BorrowFlowExecutionFacade = BorrowFlowExecutionModule["facade"]; -const borrowFlowSessionRootAtomFamily = Atom.family( +export const borrowFlowSessionRootAtomFamily = Atom.family( makeBorrowFlowSessionModule ); - -export const currentBorrowFlowSessionRootAtom = Atom.make((get) => { - const session = get(currentBorrowFlowSessionAtom); - return session ? borrowFlowSessionRootAtomFamily(session) : null; -}).pipe(Atom.withLabel("currentBorrowFlowSessionRootAtom")); diff --git a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow.ts b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow.ts index 7aa8564a9..8748c8aee 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/atoms/borrow-flow.ts @@ -9,15 +9,23 @@ export const borrowTransactionFlowServiceAtom = walletRuntime .atom(Effect.service(BorrowTransactionFlowService)) .pipe(Atom.keepAlive, Atom.withLabel("borrowTransactionFlowServiceAtom")); -const currentBorrowFlowSessionResultAtom = walletRuntime - .atom((context) => - Stream.unwrap( - context - .result(borrowTransactionFlowServiceAtom) - .pipe(Effect.map((service) => service.currentSession)) +// Keep route admission independent of previously retained stream values. +export const makeBorrowFlowRouteSessionAtom = () => + walletRuntime + .atom((context) => + Stream.unwrap( + context + .result(borrowTransactionFlowServiceAtom) + .pipe(Effect.map((service) => service.currentSession)) + ) ) - ) - .pipe(Atom.keepAlive, Atom.withLabel("currentBorrowFlowSessionResultAtom")); + .pipe(Atom.withLabel("borrowFlowRouteSession")); + +const currentBorrowFlowSessionResultAtom = + makeBorrowFlowRouteSessionAtom().pipe( + Atom.keepAlive, + Atom.withLabel("currentBorrowFlowSessionResultAtom") + ); export const currentBorrowFlowSessionAtom = Atom.make((get) => AsyncResult.getOrElse(get(currentBorrowFlowSessionResultAtom), () => null) diff --git a/packages/widget/src/features/classic-transaction-flow/react/classic-flow-route.tsx b/packages/widget/src/features/classic-transaction-flow/react/classic-flow-route.tsx index 025372dc8..6b3d9b71c 100644 --- a/packages/widget/src/features/classic-transaction-flow/react/classic-flow-route.tsx +++ b/packages/widget/src/features/classic-transaction-flow/react/classic-flow-route.tsx @@ -1,18 +1,23 @@ import { make as makeScopedAtom, useAtomValue } from "@effect/atom-react"; -import type * as Atom from "effect/unstable/reactivity/Atom"; -import { createContext, type PropsWithChildren, useContext } from "react"; +import { + createContext, + type PropsWithChildren, + useContext, + useState, +} from "react"; import { Navigate, Outlet } from "react-router"; +import { LoadingSkeleton } from "../../../shared/ui/components/loading-skeleton"; import { type ClassicTransactionFlowIntake, getClassicTransactionFlowIntakeVariant, } from "../model/classic-transaction-flow"; -import { currentClassicFlowSessionAtom } from "../state/atoms/classic-flow"; +import { makeClassicFlowRouteSessionAtom } from "../state/atoms/classic-flow"; import { type ClassicFlowExecutionFacade, type ClassicFlowReviewFacade, type ClassicFlowSessionFacade, type ClassicFlowSessionModule, - currentClassicFlowSessionRootAtom, + classicFlowSessionRootAtomFamily, makeClassicFlowExecutionScope, makeClassicFlowReviewScope, } from "../state/atoms/classic-flow-session"; @@ -59,31 +64,31 @@ export const ClassicFlowRoute = ({ }: { readonly expected: ClassicTransactionFlowIntake["_tag"]; }) => { - const session = useAtomValue(currentClassicFlowSessionAtom); + const [sessionAtom] = useState(makeClassicFlowRouteSessionAtom); + const result = useAtomValue(sessionAtom); + if (result._tag === "Initial") return ; + if (result._tag === "Failure") return ; + const session = result.value; const intake = session ? getClassicTransactionFlowIntakeVariant(session.intake, expected) : null; if (session && intake) { - return ; + return ( + + ); } return ; }; -const SessionBinding = () => { - const rootAtom = useAtomValue(currentClassicFlowSessionRootAtom); - if (!rootAtom) return ; - - return ; -}; - const MountedSessionBinding = ({ rootAtom, }: { - readonly rootAtom: NonNullable< - Atom.Type - >; + readonly rootAtom: ReturnType; }) => { const session = useAtomValue(rootAtom); diff --git a/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow-session.ts b/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow-session.ts index e632990d5..417ea0559 100644 --- a/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow-session.ts +++ b/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow-session.ts @@ -7,10 +7,7 @@ import { type ClassicTransactionFlowIntake, getClassicTransactionFlowIntakeVariant, } from "../../model/classic-transaction-flow"; -import { - classicTransactionFlowServiceAtom, - currentClassicFlowSessionAtom, -} from "./classic-flow"; +import { classicTransactionFlowServiceAtom } from "./classic-flow"; import { makeClassicFlowExecutionScopeAtom } from "./classic-flow-execution"; import { makeClassicFlowReviewScopeAtom } from "./classic-flow-review"; @@ -82,11 +79,6 @@ type ClassicFlowExecutionModule = Atom.Type< >; export type ClassicFlowExecutionFacade = ClassicFlowExecutionModule["facade"]; -const classicFlowSessionRootAtomFamily = Atom.family( +export const classicFlowSessionRootAtomFamily = Atom.family( makeClassicFlowSessionModule ); - -export const currentClassicFlowSessionRootAtom = Atom.make((get) => { - const session = get(currentClassicFlowSessionAtom); - return session ? classicFlowSessionRootAtomFamily(session) : null; -}).pipe(Atom.withLabel("currentClassicFlowSessionRootAtom")); diff --git a/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow.ts b/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow.ts index 9e649ddcf..1e8a28b76 100644 --- a/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow.ts +++ b/packages/widget/src/features/classic-transaction-flow/state/atoms/classic-flow.ts @@ -19,15 +19,24 @@ export const classicTransactionFlowServiceAtom = walletRuntime .atom(Effect.service(ClassicTransactionFlowService)) .pipe(Atom.keepAlive, Atom.withLabel("classicTransactionFlowServiceAtom")); -const currentClassicFlowSessionResultAtom = walletRuntime - .atom((context) => - Stream.unwrap( - context - .result(classicTransactionFlowServiceAtom) - .pipe(Effect.map((service) => service.currentSession)) +// Route guards need a fresh replay on each mount, not a retained projection +// that may still describe the session before navigation. +export const makeClassicFlowRouteSessionAtom = () => + walletRuntime + .atom((context) => + Stream.unwrap( + context + .result(classicTransactionFlowServiceAtom) + .pipe(Effect.map((service) => service.currentSession)) + ) ) - ) - .pipe(Atom.keepAlive, Atom.withLabel("currentClassicFlowSessionResultAtom")); + .pipe(Atom.withLabel("classicFlowRouteSession")); + +const currentClassicFlowSessionResultAtom = + makeClassicFlowRouteSessionAtom().pipe( + Atom.keepAlive, + Atom.withLabel("currentClassicFlowSessionResultAtom") + ); export const currentClassicFlowSessionAtom = Atom.make((get) => AsyncResult.getOrElse(get(currentClassicFlowSessionResultAtom), () => null) diff --git a/packages/widget/src/shared/ui/components/loading-skeleton/index.tsx b/packages/widget/src/shared/ui/components/loading-skeleton/index.tsx new file mode 100644 index 000000000..7af39aca9 --- /dev/null +++ b/packages/widget/src/shared/ui/components/loading-skeleton/index.tsx @@ -0,0 +1,7 @@ +import { ContentLoaderSquare } from "../../primitives/content-loader"; + +export const LoadingSkeleton = () => ( +
+ +
+); diff --git a/packages/widget/tests/components/loading-skeleton.browser.test.tsx b/packages/widget/tests/components/loading-skeleton.browser.test.tsx new file mode 100644 index 000000000..87c1b16bb --- /dev/null +++ b/packages/widget/tests/components/loading-skeleton.browser.test.tsx @@ -0,0 +1,19 @@ +import { expect, it } from "vitest"; +import { render } from "vitest-browser-react"; +import { LoadingSkeleton } from "../../src/shared/ui/components/loading-skeleton"; + +it("reserves space while route state is loading", async () => { + const app = await render( +
+ +
+ ); + const loading = app.container.querySelector('[aria-busy="true"]'); + const skeleton = app.container.querySelector(".react-loading-skeleton"); + + expect(loading).not.toBeNull(); + expect(skeleton).not.toBeNull(); + expect(loading!.getBoundingClientRect().height).toBeGreaterThanOrEqual(320); + expect(skeleton!.getBoundingClientRect().height).toBe(320); + expect(skeleton!.getBoundingClientRect().width).toBe(360); +}); diff --git a/packages/widget/tests/features/borrow-flow-atoms.test.ts b/packages/widget/tests/features/borrow-flow-atoms.test.ts index bdec04f70..61ba77ea4 100644 --- a/packages/widget/tests/features/borrow-flow-atoms.test.ts +++ b/packages/widget/tests/features/borrow-flow-atoms.test.ts @@ -17,7 +17,7 @@ import { startBorrowTransactionFlowAtom, } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow"; import { - currentBorrowFlowSessionRootAtom, + borrowFlowSessionRootAtomFamily, makeBorrowFlowExecutionScope, makeBorrowFlowReviewScope, } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow-session"; @@ -147,9 +147,9 @@ describe("Borrow Flow Atom bridge", () => { ); expect(startInputs).toEqual([intake]); - const rootAtom = registry.get(currentBorrowFlowSessionRootAtom); - if (!rootAtom) - throw new Error("Expected a Borrow Flow Session root Atom"); + const session = registry.get(currentBorrowFlowSessionAtom); + if (!session) throw new Error("Expected a Borrow Flow Session"); + const rootAtom = borrowFlowSessionRootAtomFamily(session); const releaseRoot = registry.mount(rootAtom); yield* Effect.promise(() => vi.waitFor(() => expect(probes.acquired).toBe(1)) @@ -245,8 +245,7 @@ describe("Borrow Flow Atom bridge", () => { ], }); - const sessionRootAtom = registry.get(currentBorrowFlowSessionRootAtom); - if (!sessionRootAtom) throw new Error("Expected a Session root Atom"); + const sessionRootAtom = borrowFlowSessionRootAtomFamily(session); const releaseSession = registry.mount(sessionRootAtom); const sessionModule = registry.get(sessionRootAtom); diff --git a/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx b/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx index 5f69499dd..aff6b804d 100644 --- a/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx +++ b/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx @@ -1,10 +1,11 @@ import { RegistryProvider } from "@effect/atom-react"; +import { Effect, Layer, Stream } from "effect"; import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import { describe, expect, it } from "vitest"; +import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; import type { BorrowFlowSession } from "../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { BorrowTransactionFlowRoute } from "../../src/features/borrow-transaction-flow/react/borrow-flow-route"; -import { currentBorrowFlowSessionAtom } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow"; -import { currentBorrowFlowSessionRootAtom } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow-session"; +import { BorrowTransactionFlowService } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-transaction-flow-service"; import { render } from "../utils/test-utils.dom.tsx"; const LocationProbe = () => { @@ -23,8 +24,15 @@ describe("Borrow Flow route identity", () => { const app = await render( + Effect.die("Mismatched session must not be acquired"), + start: () => Effect.die("Not used"), + }) as never, + ], ]} > diff --git a/packages/widget/tests/features/classic-flow-atoms.test.ts b/packages/widget/tests/features/classic-flow-atoms.test.ts index a1c739cc5..b150c23ad 100644 --- a/packages/widget/tests/features/classic-flow-atoms.test.ts +++ b/packages/widget/tests/features/classic-flow-atoms.test.ts @@ -15,7 +15,7 @@ import { isActiveClassicTransactionFlowPathAtom, startClassicTransactionFlowAtom, } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow"; -import { currentClassicFlowSessionRootAtom } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow-session"; +import { classicFlowSessionRootAtomFamily } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow-session"; import type { ClassicFlowSessionHandle } from "../../src/features/classic-transaction-flow/state/orchestration/classic-flow-session"; import { ClassicTransactionFlowService } from "../../src/features/classic-transaction-flow/state/orchestration/classic-transaction-flow-service"; import { toWidgetPath } from "../../src/services/navigation/widget-navigation"; @@ -151,9 +151,9 @@ describe("Classic Flow Atom bridge", () => { registry.get(isActiveClassicTransactionFlowPathAtom("/review")) ).toBe(true); - const rootAtom = registry.get(currentClassicFlowSessionRootAtom); - if (!rootAtom) - throw new Error("Expected a Classic Flow Session root Atom"); + const session = registry.get(currentClassicFlowSessionAtom); + if (!session) throw new Error("Expected a Classic Flow Session"); + const rootAtom = classicFlowSessionRootAtomFamily(session); const releaseRoot = registry.mount(rootAtom); yield* Effect.promise(() => vi.waitFor(() => expect(probes.acquired).toBe(1)) diff --git a/packages/widget/tests/features/transaction-flow-route-readiness.dom.test.tsx b/packages/widget/tests/features/transaction-flow-route-readiness.dom.test.tsx new file mode 100644 index 000000000..80e28989e --- /dev/null +++ b/packages/widget/tests/features/transaction-flow-route-readiness.dom.test.tsx @@ -0,0 +1,458 @@ +import { RegistryProvider, useAtomValue } from "@effect/atom-react"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + Deferred, + Effect, + Layer, + Schema, + Stream, + SubscriptionRef, +} from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { act } from "react"; +import { createMemoryRouter, Outlet, RouterProvider } from "react-router"; +import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; +import { Action } from "../../src/domain/borrow/execution/action"; +import { IntegrationId, MarketId } from "../../src/domain/borrow/ids"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { BorrowFlowSession } from "../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; +import { + BorrowTransactionFlowCompletionGuard, + BorrowTransactionFlowExecutionScope, + BorrowTransactionFlowRoute, + useBorrowTransactionFlowExecution, +} from "../../src/features/borrow-transaction-flow/react/borrow-flow-route"; +import { currentBorrowFlowSessionAtom } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow"; +import { BorrowTransactionFlowService } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-transaction-flow-service"; +import type { ClassicFlowSession } from "../../src/features/classic-transaction-flow/model/classic-transaction-flow"; +import { ClassicFlowRoute } from "../../src/features/classic-transaction-flow/react/classic-flow-route"; +import { currentClassicFlowSessionAtom } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow"; +import { ClassicTransactionFlowService } from "../../src/features/classic-transaction-flow/state/orchestration/classic-transaction-flow-service"; +import { toWidgetPath } from "../../src/services/navigation/widget-navigation"; +import { initializeTransactionWorkflow } from "../../src/services/transaction-workflow/internal/model"; +import { + BorrowTransactionWorkflowInput, + type TransactionWorkflowState, +} from "../../src/services/transaction-workflow/transaction-workflow-model"; +import { yieldApiYieldFixture } from "../fixtures"; +import { render } from "../utils/test-utils.dom.tsx"; + +const address = Schema.decodeSync(WalletAddress)("0xWallet"); +const walletScope = new WalletScopeKey({ address, network: "ethereum" }); +const selectedStake = yieldApiYieldFixture(); +const classicSession: ClassicFlowSession = { + epoch: 1, + destination: { + reviewPath: toWidgetPath("/review"), + stepsPath: toWidgetPath("/steps"), + completePath: toWidgetPath("/complete"), + }, + mount: { _tag: "Earn" }, + intake: { + _tag: "Enter", + gasFeeToken: selectedStake.mechanics.gasFeeToken, + providersDetails: [], + request: { address, yieldId: selectedStake.id }, + selectedStake, + selectedToken: selectedStake.token, + selectedValidators: new Map(), + walletScope, + }, +}; +const borrowSession: BorrowFlowSession = { + epoch: 1, + walletScope, + intake: { + command: { + action: "borrow", + address, + args: { marketId: Schema.decodeSync(MarketId)("market-1") }, + integrationId: Schema.decodeSync(IntegrationId)("provider-1"), + }, + entry: { _tag: "BorrowEntry" }, + summary: { + action: "borrow", + borrowAmount: "1", + existingCollateralUsd: "100", + existingDebtUsd: "0", + loanTokenSymbol: "USDC", + marketLabel: "USDC market", + network: "ethereum", + projectedCollateralUsd: "100", + projectedDebtUsd: "1", + providerName: "Provider", + riskStatus: "unavailable", + warnings: [], + }, + }, +}; + +const makeDelayedSession = Effect.fn("test.makeDelayedSession")(function* ( + session: A +) { + const current = yield* SubscriptionRef.make(session); + const ready = yield* Deferred.make(); + return { + current, + ready, + states: Stream.unwrap( + Deferred.await(ready).pipe(Effect.as(SubscriptionRef.changes(current))) + ), + }; +}); + +const actEffect = (effect: Effect.Effect) => + Effect.promise(() => + act(async () => { + // ast-grep-ignore: no-run-effect-in-test -- React act is a Promise-based boundary; keep Effect-driven React updates inside it. + await Effect.runPromise(effect); + }) + ); + +describe("Transaction Flow route admission", () => { + it.live.each([ + { kind: "Classic", projection: "cold" }, + { kind: "Classic", projection: "retained null" }, + { kind: "Borrow", projection: "cold" }, + { kind: "Borrow", projection: "retained null" }, + ] as const)( + "$kind waits for authoritative state with a $projection projection, and reads afresh on re-entry", + ({ kind, projection }) => + Effect.gen(function* () { + const classic = yield* makeDelayedSession(classicSession); + const borrow = yield* makeDelayedSession(borrowSession); + const acquired = vi.fn(); + const released = vi.fn(); + const classicService = ClassicTransactionFlowService.of({ + acquireSession: (session) => + Effect.acquireRelease( + Effect.sync(() => { + acquired(session.epoch); + return { + _tag: "Acquired", + session: { + intake: session.intake, + acquireReview: () => Effect.die("Not used"), + acquireExecution: () => Effect.die("Not used"), + }, + } as const; + }), + () => Effect.sync(() => released()) + ), + currentSession: classic.states, + start: () => Effect.die("Not used"), + }); + const borrowService = BorrowTransactionFlowService.of({ + acquireSession: (session) => + Effect.acquireRelease( + Effect.sync(() => { + acquired(session.epoch); + return { + _tag: "Acquired", + session: { + intake: session.intake, + acquireReview: () => Effect.die("Not used"), + acquireExecution: () => Effect.die("Not used"), + }, + } as const; + }), + () => Effect.sync(() => released()) + ), + currentSession: borrow.states, + start: () => Effect.die("Not used"), + }); + const router = createMemoryRouter( + [ + { path: "/", element:
Entry
}, + { path: "/borrow", element:
Entry
}, + { + element: + kind === "Classic" ? ( + + ) : ( + + ), + children: [{ path: "/review", element:
Review
}], + }, + ], + { initialEntries: ["/review"] } + ); + const app = yield* Effect.promise(() => + render( + + + + ) + ); + + expect(router.state.location.pathname).toBe("/review"); + expect( + app.container.querySelector('[aria-busy="true"]') + ).not.toBeNull(); + yield* actEffect( + Effect.all([ + Deferred.succeed(classic.ready, undefined), + Deferred.succeed(borrow.ready, undefined), + ]) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(app.container.textContent).toBe("Review")) + ); + expect(acquired).toHaveBeenCalledWith(1); + + yield* Effect.promise(() => + act(async () => { + await router.navigate("/"); + }) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(released).toHaveBeenCalledTimes(1)) + ); + yield* SubscriptionRef.set(classic.current, { + ...classicSession, + epoch: 2, + }); + yield* SubscriptionRef.set(borrow.current, { + ...borrowSession, + epoch: 2, + }); + yield* Effect.promise(() => + act(async () => { + await router.navigate("/review"); + }) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(acquired).toHaveBeenLastCalledWith(2)) + ); + expect(router.state.location.pathname).toBe("/review"); + + yield* actEffect( + Effect.all([ + SubscriptionRef.set(classic.current, null), + SubscriptionRef.set(borrow.current, null), + ]) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(app.container.textContent).toBe("Entry")) + ); + // A fresh visit with genuinely absent authoritative state still redirects. + yield* Effect.promise(() => + act(async () => { + await router.navigate("/review"); + }) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(app.container.textContent).toBe("Entry")) + ); + expect(acquired).toHaveBeenCalledTimes(2); + app.unmount(); + router.dispose(); + }) + ); +}); + +const BorrowExecutionProbe = () => { + const execution = useBorrowTransactionFlowExecution(); + const view = useAtomValue(execution.viewAtom); + return ( + <> + {view.isDone ? "done" : "pending"} + + + ); +}; + +describe("Borrow completion admission", () => { + it.live( + "does not bounce to Steps while its existing execution projection is behind", + () => + Effect.gen(function* () { + const action = yield* Schema.decodeEffect(Action)({ + action: "borrow", + address, + createdAt: "2026-01-01T00:00:00.000Z", + currentStep: 1, + hasNextStep: false, + id: "action-1", + integrationId: "provider-1", + rawArguments: borrowSession.intake.command.args, + status: "CREATED", + totalSteps: 1, + transactions: [], + }); + const initial = initializeTransactionWorkflow( + new BorrowTransactionWorkflowInput({ action, walletScope }) + ); + const completed: TransactionWorkflowState = { + _tag: "Completed", + context: initial.context, + }; + const current = + yield* SubscriptionRef.make(initial); + const ready = yield* Deferred.make(); + const projectionReady = yield* Deferred.make(); + let subscriptions = 0; + const states = Stream.unwrap( + Effect.sync(() => { + subscriptions += 1; + // Delay the existing view independently of the fresh route read. + return subscriptions === 1 + ? Stream.concat( + Stream.succeed(initial), + Stream.unwrap( + Deferred.await(projectionReady).pipe( + Effect.as(SubscriptionRef.changes(current)) + ) + ) + ) + : Stream.unwrap( + Deferred.await(ready).pipe( + Effect.as(SubscriptionRef.changes(current)) + ) + ); + }) + ); + const acquireExecution = vi.fn(() => + Effect.succeed({ + _tag: "Acquired" as const, + execution: { + states, + back: () => Effect.succeed({ _tag: "Accepted" as const }), + finish: () => Effect.succeed({ _tag: "Accepted" as const }), + runWorkflow: () => Effect.succeed({ _tag: "Accepted" as const }), + }, + }) + ); + const service = BorrowTransactionFlowService.of({ + currentSession: Stream.concat( + Stream.succeed(borrowSession), + Stream.never + ), + start: () => Effect.die("Not used"), + acquireSession: () => + Effect.succeed({ + _tag: "Acquired", + session: { + intake: borrowSession.intake, + acquireExecution, + acquireReview: () => Effect.die("Not used"), + }, + }), + }); + const router = createMemoryRouter( + [ + { path: "/borrow", element:
Entry
}, + { + element: , + children: [ + { + element: ( + + + + ), + children: [ + { path: "/borrow/steps", element:
Steps
}, + { + element: , + children: [ + { + path: "/borrow/complete", + element:
Complete
, + }, + ], + }, + ], + }, + ], + }, + ], + { initialEntries: ["/borrow/steps"] } + ); + const app = yield* Effect.promise(() => + render( + + + + ) + ); + yield* Effect.promise(() => + vi.waitFor(() => expect(app.container.textContent).toContain("Steps")) + ); + expect(app.container.textContent).toContain("pending"); + yield* SubscriptionRef.set(current, completed); + yield* Effect.promise(() => + act(async () => { + await router.navigate("/borrow/complete"); + }) + ); + expect(router.state.location.pathname).toBe("/borrow/complete"); + expect( + app.container.querySelector('[aria-busy="true"]') + ).not.toBeNull(); + yield* actEffect(Deferred.succeed(ready, undefined)); + expect(router.state.location.pathname).toBe("/borrow/complete"); + expect( + app.container.querySelector('[aria-busy="true"]') + ).not.toBeNull(); + expect(app.container.textContent).toContain("pending"); + yield* actEffect(Deferred.succeed(projectionReady, undefined)); + yield* Effect.promise(() => + vi.waitFor(() => + expect(app.container.textContent).toContain("Complete") + ) + ); + expect(app.container.textContent).toContain("done"); + expect(acquireExecution).toHaveBeenCalledTimes(1); + + // Revisiting Complete must not reuse its previous successful admission. + yield* Effect.promise(() => + act(async () => { + await router.navigate("/borrow/steps"); + }) + ); + yield* actEffect(SubscriptionRef.set(current, initial)); + yield* Effect.promise(() => + act(async () => { + await router.navigate("/borrow/complete"); + }) + ); + yield* Effect.promise(() => + vi.waitFor(() => + expect(router.state.location.pathname).toBe("/borrow/steps") + ) + ); + expect(acquireExecution).toHaveBeenCalledTimes(1); + app.unmount(); + router.dispose(); + }) + ); +}); diff --git a/packages/widget/tests/hooks/action-preview.dom.test.tsx b/packages/widget/tests/hooks/action-preview.dom.test.tsx index 3f4b8bf1c..83fe21ecd 100644 --- a/packages/widget/tests/hooks/action-preview.dom.test.tsx +++ b/packages/widget/tests/hooks/action-preview.dom.test.tsx @@ -9,8 +9,9 @@ import { ActionCommand } from "../../src/domain/action/models"; import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; import { startClassicTransactionFlowAtom } from "../../src/features/classic-transaction-flow/index"; import type { ClassicTransactionFlowIntake } from "../../src/features/classic-transaction-flow/model/classic-transaction-flow"; +import { currentClassicFlowSessionAtom } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow"; import { - currentClassicFlowSessionRootAtom, + classicFlowSessionRootAtomFamily, makeClassicFlowExecutionScope, makeClassicFlowReviewScope, } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow-session"; @@ -106,9 +107,7 @@ const settings = getTestWidgetConfig({ }); const reviewScopeAtomFamily = Atom.family( - ( - rootAtom: NonNullable> - ) => + (rootAtom: ReturnType) => (() => { let reviewAtom: ReturnType | undefined; @@ -120,8 +119,10 @@ const reviewScopeAtomFamily = Atom.family( })() ); const sessionReviewFacadeAtom = Atom.make((get) => { - const rootAtom = get(currentClassicFlowSessionRootAtom); - return rootAtom ? get(reviewScopeAtomFamily(rootAtom)) : null; + const session = get(currentClassicFlowSessionAtom); + return session + ? get(reviewScopeAtomFamily(classicFlowSessionRootAtomFamily(session))) + : null; }); const sessionReviewViewAtom = Atom.make((get) => { const review = get(sessionReviewFacadeAtom); @@ -145,10 +146,10 @@ const confirmSessionAtom = Atom.fnSync( { initialValue: undefined } ); const sessionAttachedActionAtom = Atom.make((get) => { - const rootAtom = get(currentClassicFlowSessionRootAtom); - if (!rootAtom) return null; + const session = get(currentClassicFlowSessionAtom); + if (!session) return null; - const flow = get(rootAtom); + const flow = get(classicFlowSessionRootAtomFamily(session)); const execution = get(makeClassicFlowExecutionScope(flow)); return AsyncResult.getOrElse(get(execution.availabilityAtom), () => null); });