Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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";
Expand DownExpand Up@@ -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 <SessionBinding entry={session.intake.entry} key={session.epoch} />;
}
const [sessionAtom] = useState(makeBorrowFlowRouteSessionAtom);
const result = useAtomValue(sessionAtom);
const fallbackPath = getEntryFallbackPath(expected, marketId);
return <Navigate replace to={fallbackPath} />;
};

const SessionBinding = ({
entry,
}: {
readonly entry: BorrowTransactionFlowEntry;
}) => {
const rootAtom = useAtomValue(currentBorrowFlowSessionRootAtom);
if (!rootAtom) {
if (result._tag === "Initial") return <LoadingSkeleton />;
if (result._tag === "Failure") return <Navigate replace to={fallbackPath} />;
const session = result.value;
if (session && matchesEntry(session.intake.entry, expected, marketId)) {
return (
<Navigate replace to={getBorrowTransactionFlowRoutes(entry).basePath} />
<MountedSessionBinding
key={session.epoch}
rootAtom={borrowFlowSessionRootAtomFamily(session)}
/>
);
}
return <MountedSessionBinding rootAtom={rootAtom} />;
return <Navigate replace to={fallbackPath} />;
};

const MountedSessionBinding = ({
rootAtom,
}: {
readonly rootAtom: NonNullable<
Atom.Type<typeof currentBorrowFlowSessionRootAtom>
>;
readonly rootAtom: ReturnType<typeof borrowFlowSessionRootAtomFamily>;
}) => {
const session = useAtomValue(rootAtom);
return (
Expand DownExpand Up@@ -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 ? <Outlet /> : <Navigate replace to={stepsPath} />;
if (result._tag === "Initial") return <LoadingSkeleton />;
if (result._tag === "Failure" || !result.value) {
return <Navigate replace to={stepsPath} />;
}
// Admission is authoritative; the page still needs its completion details.
if (!view.isDone) return <LoadingSkeleton />;
return <Outlet />;
};
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,27 @@ export const makeBorrowFlowExecutionScopeAtom = <E>(
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));
Expand DownExpand Up@@ -96,6 +117,7 @@ export const makeBorrowFlowExecutionScopeAtom = <E>(
facade: {
backAtom,
finishAtom,
makeCompletionStateAtom,
viewAtom,
workflowCommandAtom,
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand DownExpand Up@@ -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"));
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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";
Expand DownExpand Up@@ -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 <LoadingSkeleton />;
if (result._tag === "Failure") return <Navigate to="/" replace />;
const session = result.value;
const intake = session
? getClassicTransactionFlowIntakeVariant(session.intake, expected)
: null;

if (session && intake) {
return <SessionBinding key={session.epoch} />;
return (
<MountedSessionBinding
key={session.epoch}
rootAtom={classicFlowSessionRootAtomFamily(session)}
/>
);
}

return <Navigate to="/" replace />;
};

const SessionBinding = () => {
const rootAtom = useAtomValue(currentClassicFlowSessionRootAtom);
if (!rootAtom) return <Navigate to="/" replace />;

return <MountedSessionBinding rootAtom={rootAtom} />;
};

const MountedSessionBinding = ({
rootAtom,
}: {
readonly rootAtom: NonNullable<
Atom.Type<typeof currentClassicFlowSessionRootAtom>
>;
readonly rootAtom: ReturnType<typeof classicFlowSessionRootAtomFamily>;
}) => {
const session = useAtomValue(rootAtom);

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand DownExpand Up@@ -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"));
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
import { ContentLoaderSquare } from "../../primitives/content-loader";

export const LoadingSkeleton = () => (
<div aria-busy="true">
<ContentLoaderSquare heightPx={320} />
</div>
);
Original file line numberDiff line numberDiff line change
@@ -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(
<div style={{ width: 360 }}>
<LoadingSkeleton />
</div>
);
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);
});
11 changes: 5 additions & 6 deletions packages/widget/tests/features/borrow-flow-atoms.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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))
Expand DownExpand Up@@ -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);

Expand Down
Loading
Loading