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 (
+ <>
+
+
+ >
+ );
+};
+
+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);
});