diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md
index 6506fef83..c6229d356 100644
--- a/packages/widget/ARCHITECTURE.md
+++ b/packages/widget/ARCHITECTURE.md
@@ -43,6 +43,29 @@ Widget root, a prop the Layout's composition supplies that names the
difference, or a Layout-specific component. See
[ADR 0013](../../docs/adr/0013-layouts-are-application-composition.md).
+`app/layouts/layout-app-container.tsx` selects the Layout attribute and page
+transition. The shared shell receives that resolved presentation; it does not
+select a Layout or own Layout-specific transition presets.
+
+## Routes and navigation
+
+The Route Registry (`services/navigation/widget-routes.ts`) defines every
+absolute Widget path once: its pattern, typed params, the Layouts that mount
+it, how to build it, and how to match a pathname against it. Route trees mount
+registry patterns, navigation accepts only registry-built `WidgetPath`s, and
+pathname parsing uses registry matching; only relative redirects stay literal.
+Building a route that some Layout does not mount requires naming the Layout. A
+Transaction Flow's Review, Steps, and Complete form one flow group per mount.
+The owning Feature or application composition binds the navigation-state schema
+to its route or flow group. Presentation changes carried by navigation apply
+only after the router accepts it, so blocked navigation cannot remount the
+current page. The `navigation` Feature adapts the registry to React.
+
+Navigation that completes a domain operation, such as a handoff with its
+navigation or a deep link, runs in that Effect operation through the
+`WidgetNavigation` service. Navigation that only answers a user's UI action,
+such as a tab press or a back button, stays in React.
+
## Dependency direction
Dependencies point toward narrow interfaces and stable lower-level concepts:
@@ -246,8 +269,9 @@ and Effect Atom registry.
memoize by hand), ad hoc Atom runtimes, direct Effect runtime execution,
native `Date`, global `fetch`, throws in Effect generators, wildcard Module
exports, Resources that read an API ResourceSource without
- `makePresentableResource`, Layout reads outside application composition and
- Widget Configuration, Layout-named identifiers in Domain, Resource, service, and
+ `makePresentableResource`, literal absolute Widget paths outside the Route
+ Registry, Layout reads outside application composition and Widget
+ Configuration, Layout-named identifiers in Domain, Resource, service, and
Feature `model/` and `state/` code, and Layout-named files outside `ui/` and
`app/layouts/`.
- Architecture rules live in these tools, not in tests that read source text.
diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx
index ac38bcb9e..77bb296a3 100644
--- a/packages/widget/src/App.tsx
+++ b/packages/widget/src/App.tsx
@@ -10,6 +10,7 @@ import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime
import { WidgetConfigurationUnavailable } from "./app/composition/widget-configuration-unavailable";
import { CompactRoutes } from "./app/layouts/compact/compact-routes";
import { DashboardRoutes } from "./app/layouts/dashboard/dashboard-routes";
+import { LayoutAppContainer } from "./app/layouts/layout-app-container";
import { applicationRoutes } from "./app/routes/application-routes";
import { useApplicationRouteEffects } from "./app/routes/react/use-application-route-effects";
import { applicationRouterAtom } from "./app/runtime/application-router";
@@ -22,7 +23,6 @@ import {
useWidgetConfig,
} from "./features/widget-configuration/index";
import {
- AppContainer,
NetworkNotSupported,
NoEnabledYields,
} from "./features/widget-shell/composition";
@@ -73,9 +73,9 @@ const SKAppRouter = () => {
return (
}>
-
+
-
+
);
};
diff --git a/packages/widget/src/app/composition/providers/mount-animation/index.tsx b/packages/widget/src/app/composition/providers/mount-animation/index.tsx
index e2a5aece9..59e1c0666 100644
--- a/packages/widget/src/app/composition/providers/mount-animation/index.tsx
+++ b/packages/widget/src/app/composition/providers/mount-animation/index.tsx
@@ -1,22 +1,23 @@
import { useAtomMount } from "@effect/atom-react";
import { useEffect } from "react";
-import { useLocation } from "react-router";
import {
mountAnimationCompletionAtom,
useMountAnimation,
} from "../../../../features/mount-animation/index";
+import { useWidgetRouteMatch } from "../../../../features/navigation/index";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
export const MountAnimationEffects = () => {
- const location = useLocation();
+ const onEarn = useWidgetRouteMatch(widgetRoutes.earn) !== null;
const { dispatch } = useMountAnimation();
useAtomMount(mountAnimationCompletionAtom);
useEffect(() => {
- if (location.pathname !== "/") {
+ if (!onEarn) {
dispatch({ type: "all" });
}
- }, [location.pathname, dispatch]);
+ }, [onEarn, dispatch]);
return null;
};
diff --git a/packages/widget/src/app/composition/widget-configuration-unavailable.tsx b/packages/widget/src/app/composition/widget-configuration-unavailable.tsx
index 53c90af2c..e9712187c 100644
--- a/packages/widget/src/app/composition/widget-configuration-unavailable.tsx
+++ b/packages/widget/src/app/composition/widget-configuration-unavailable.tsx
@@ -1,11 +1,9 @@
import { useState } from "react";
import { I18nextProvider } from "react-i18next";
-import {
- AppContainer,
- WidgetUnavailableWithoutConfiguration,
-} from "../../features/widget-shell/composition";
+import { WidgetUnavailableWithoutConfiguration } from "../../features/widget-shell/composition";
import type { RejectedConfigurationPresentation } from "../../services/config/widget-config-boundary";
import { createWidgetI18nInstance } from "../../services/translation/widget-translation";
+import { LayoutAppContainer } from "../layouts/layout-app-container";
import { ThemeStyles } from "./providers/theme-wrapper";
/**
@@ -25,9 +23,9 @@ export const WidgetConfigurationUnavailable = ({
return (
-
+
-
+
);
diff --git a/packages/widget/src/app/layouts/compact/compact-routes.tsx b/packages/widget/src/app/layouts/compact/compact-routes.tsx
index c92d8d4e3..4f7698970 100644
--- a/packages/widget/src/app/layouts/compact/compact-routes.tsx
+++ b/packages/widget/src/app/layouts/compact/compact-routes.tsx
@@ -5,6 +5,7 @@ import {
createActivityActionRoutes,
} from "../../../features/activity/composition";
import { AnimatedEarnPage } from "../../../features/earn/composition";
+import { WidgetNavigate } from "../../../features/navigation/views";
import { AnimatedPositionsPage } from "../../../features/portfolio/composition";
import { usePortfolioPendingActionsCount } from "../../../features/portfolio/index";
import { CompactPositionDetailsPage } from "../../../features/position-details/composition";
@@ -24,12 +25,87 @@ import {
} from "../../../features/widget-shell/views";
import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition";
import { getYieldFlowRouteGroup } from "../../../features/yield-transaction-flow/index";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
const CompactDetails = () => {
const pendingActionsCount = usePortfolioPendingActionsCount();
return ;
};
+/** The Compact Layout's route tree. */
+export const createCompactRoutes = () => {
+ const earnPath = widgetRoutes.earn.build();
+
+ return (
+ }>
+ {/* Home + Tabs */}
+ }>
+ } />
+ }
+ />
+
+ } />
+
+ }
+ >
+ {createActivityActionRoutes({
+ selectsDefaultAction: false,
+ showsBackButton: true,
+ })}
+
+
+
+
+ }>
+ {/* Stake flow */}
+ {createYieldFlowRoutes({ mount: "Earn" })}
+
+ {/* Position details hub + flow mounts */}
+
+ } />
+ }
+ />
+
+ {/* Legacy / accidental stake prefix → hub (no form) */}
+
+ } />
+ } />
+
+
+ {/* Unstaking */}
+
+ } />
+ {createYieldFlowRoutes({ mount: "PositionExit" })}
+ } />
+
+
+ {/* Pending Actions */}
+
+ } />
+ {createYieldFlowRoutes({ mount: "PositionManage" })}
+ } />
+
+
+ }
+ />
+
+
+
+ } />
+
+ );
+};
+
export const CompactRoutes = () => {
const location = useLocation();
// Review, Steps, and Complete of one flow mount share a key, so the flow
@@ -46,7 +122,7 @@ export const CompactRoutes = () => {
if (yieldFlowRouteGroup !== null) {
return `flow:${yieldFlowRouteGroup}`;
}
- if (detailsMatch) return "/";
+ if (detailsMatch) return "tabs";
return location.key;
};
const key = resolveRouteKey();
@@ -62,79 +138,7 @@ export const CompactRoutes = () => {
- }>
- {/* Home + Tabs */}
- }>
- } />
- }
- />
-
- } />
-
- }
- >
- {createActivityActionRoutes({
- selectsDefaultAction: false,
- showsBackButton: true,
- })}
-
-
-
-
- }>
- {/* Stake flow */}
- {createYieldFlowRoutes({ journey: "Enter" })}
-
- {/* Position details hub + flow mounts */}
-
- } />
- }
- />
-
- {/* Legacy / accidental stake prefix → hub (no form) */}
-
- } />
- }
- />
-
-
- {/* Unstaking */}
-
- } />
- {createYieldFlowRoutes({ journey: "Exit" })}
- }
- />
-
-
- {/* Pending Actions */}
-
- } />
- {createYieldFlowRoutes({ journey: "Manage" })}
- }
- />
-
-
- }
- />
-
-
-
- } />
-
+ {createCompactRoutes()}
diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts b/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts
new file mode 100644
index 000000000..9d86fca8f
--- /dev/null
+++ b/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts
@@ -0,0 +1,10 @@
+import { Schema } from "effect";
+import { YieldCategory } from "../../../domain/earn/contract";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+
+/** A category press only changes the page after its navigation is accepted. */
+export const dashboardEarnRoute = widgetRoutes.earn.withState(
+ Schema.Struct({
+ yieldCategory: Schema.Literals(Object.values(YieldCategory)),
+ })
+);
diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx
index fa320519d..0521b9f73 100644
--- a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx
+++ b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx
@@ -1,5 +1,6 @@
+import { Option } from "effect";
import { AnimatePresence } from "motion/react";
-import { useRef, useState } from "react";
+import { useLayoutEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router";
import type { YieldCategory } from "../../../domain/earn/contract";
import {
@@ -13,6 +14,7 @@ import {
import { createBorrowTransactionFlowRoutes } from "../../../features/borrow-transaction-flow/composition";
import { EarnPageContent } from "../../../features/earn/composition";
import { useSelectEarnCategory } from "../../../features/earn/index";
+import { useWidgetRouteState } from "../../../features/navigation/index";
import { ManagePage } from "../../../features/portfolio/composition";
import {
DashboardPositionDetailsPage,
@@ -22,7 +24,9 @@ import { WalletScopeRouteGuard } from "../../../features/wallet/composition";
import { GlobalModals } from "../../../features/widget-shell/composition";
// import { RewardsTabPage } from "../../../domain/portfolio/rewards";
import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
import { BorrowFeatureRoute } from "../../routes/ui/borrow-feature-route";
+import { dashboardEarnRoute } from "./dashboard-earn-route";
import {
DashboardEarnFlowOverview,
DashboardEarnTab,
@@ -37,141 +41,174 @@ const borrowRouteComposition = {
};
export const shouldRegisterDashboardEarnFooterButton = (pathname: string) =>
- pathname === "/";
+ Option.isSome(widgetRoutes.earn.match(pathname));
/**
* Identity of the dashboard page a pathname renders. A change replays the page
* transition (exit, then enter); steps inside one page, such as the Earn form
* into its Review or a position into its unstake flow, keep the same key.
*/
-const resolveDashboardPageKey = (pathname: string) => {
+const resolveDashboardPageKey = (pathname: string): string => {
const tab = resolveDashboardRouteTab(pathname);
if (tab !== "manage") return tab;
- // `positions`, `positions/:integrationId/:balanceId`, or
- // `positions/borrow/:marketId`
- return pathname.split("/").filter(Boolean).slice(0, 3).join("/");
+ // A position's pages share its key: a market position, a yield position,
+ // or the positions list.
+ const borrowPosition = widgetRoutes.borrowPosition.matchPrefix(pathname);
+ if (Option.isSome(borrowPosition)) {
+ return widgetRoutes.borrowPosition.build("Dashboard", borrowPosition.value);
+ }
+ const position = widgetRoutes.positionDetails.matchPrefix(pathname);
+ if (Option.isSome(position)) {
+ return widgetRoutes.positionDetails.build(position.value);
+ }
+ return widgetRoutes.positions.build();
+};
+
+/** The Dashboard Layout's route tree. */
+export const createDashboardRoutes = ({
+ registerEarnFooterButton,
+}: {
+ readonly registerEarnFooterButton: boolean;
+}) => {
+ const earnPath = widgetRoutes.earn.build();
+ const positionsPath = widgetRoutes.positions.build();
+
+ return (
+ <>
+ {/* Earn Tab */}
+ }>
+ }>
+
+ }
+ />
+
+
+ }>
+ {createYieldFlowRoutes({
+ layout: ,
+ mount: "Earn",
+ })}
+
+
+
+ {/* Borrow Tab */}
+ }>
+ {createBorrowEntryRoutes(borrowRouteComposition)}
+
+
+ {/* Manage Tab + Position Details */}
+
+ } />
+ }>
+ {createBorrowMarketPositionRoutes(borrowRouteComposition)}
+
+ }>
+ }
+ >
+ } />
+
+
+ } />
+ {createYieldFlowRoutes({ mount: "PositionStake" })}
+ } />
+
+
+ }
+ />
+
+
+ } />
+ {createYieldFlowRoutes({ mount: "PositionExit" })}
+ } />
+
+
+
+ } />
+ {createYieldFlowRoutes({ mount: "PositionManage" })}
+ } />
+
+
+ }
+ />
+
+
+
+
+ }>
+ {createActivityActionRoutes({
+ ActionScopeGuard: WalletScopeRouteGuard,
+ selectsDefaultAction: true,
+ showsBackButton: false,
+ })}
+
+ >
+ );
};
export const DashboardRoutes = () => {
const location = useLocation();
const selectCategory = useSelectEarnCategory();
- const [categoryPresses, setCategoryPresses] = useState(0);
- // A pressed category is applied once the leaving page finished its exit, so
- // it does not render the new category while fading out. The state drives
- // the tab highlight; the ref is what the exit callback reads, because
- // AnimatePresence keeps the callback from the render the exit started in.
- const [pendingYieldCategory, setPendingYieldCategory] =
- useState(null);
+ const earnNavigation = useWidgetRouteState(dashboardEarnRoute);
+ const isEarnPage = Option.isSome(widgetRoutes.earn.match(location.pathname));
+ const acceptedCategory = isEarnPage
+ ? (earnNavigation?.yieldCategory ?? null)
+ : null;
+ const [categoryTransition, setCategoryTransition] = useState<{
+ readonly key: string | null;
+ readonly category: YieldCategory | null;
+ }>({ key: null, category: null });
+
+ // A blocked navigation never changes location, so it cannot remount Steps.
+ // Keep the accepted category's key through Review, Steps, and Complete.
+ if (acceptedCategory !== null && categoryTransition.key !== location.key) {
+ setCategoryTransition({ key: location.key, category: acceptedCategory });
+ } else if (
+ acceptedCategory === null &&
+ categoryTransition.category !== null
+ ) {
+ setCategoryTransition({ ...categoryTransition, category: null });
+ }
+
+ // AnimatePresence retains the callback from when the exit began. Read the
+ // latest accepted category, including another press during that exit.
const pendingYieldCategoryRef = useRef(null);
+ useLayoutEffect(() => {
+ pendingYieldCategoryRef.current = categoryTransition.category;
+ }, [categoryTransition]);
+
const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton(
location.pathname
);
const pageKey = resolveDashboardPageKey(location.pathname);
- // Category tab presses replay the Earn page transition like route tabs do.
- const routesKey = pageKey === "earn" ? `earn:${categoryPresses}` : pageKey;
-
- const onYieldCategorySelect = (category: YieldCategory) => {
- pendingYieldCategoryRef.current = category;
- setPendingYieldCategory(category);
- setCategoryPresses((n) => n + 1);
- };
+ const routesKey =
+ pageKey === "earn" ? `earn:${categoryTransition.key}` : pageKey;
const onExitComplete = () => {
const category = pendingYieldCategoryRef.current;
if (category === null) return;
pendingYieldCategoryRef.current = null;
selectCategory(category);
- setPendingYieldCategory(null);
+ setCategoryTransition((current) => ({ ...current, category: null }));
};
return (
<>
-
+
{/* The exiting Routes keeps its location, so it renders the page being left. */}
- {/* Earn Tab */}
- }>
- }>
-
- }
- />
-
-
- }>
- {createYieldFlowRoutes({
- journey: "Enter",
- layout: ,
- })}
-
-
-
- {/* Borrow Tab */}
- }>
- {createBorrowEntryRoutes(borrowRouteComposition)}
-
-
- {/* Manage Tab + Position Details */}
-
- } />
- }>
- {createBorrowMarketPositionRoutes(borrowRouteComposition)}
-
- }
- >
- }
- >
- } />
-
-
- } />
- {createYieldFlowRoutes({ journey: "Enter" })}
- } />
-
-
- }
- />
-
-
- } />
- {createYieldFlowRoutes({ journey: "Exit" })}
- } />
-
-
-
- } />
- {createYieldFlowRoutes({ journey: "Manage" })}
- } />
-
-
- }
- />
-
-
-
-
- }>
- {createActivityActionRoutes({
- ActionScopeGuard: WalletScopeRouteGuard,
- selectsDefaultAction: true,
- showsBackButton: false,
- })}
-
+ {createDashboardRoutes({ registerEarnFooterButton })}
diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx
index 5bd1de774..55c3ff7a7 100644
--- a/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx
+++ b/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx
@@ -15,11 +15,9 @@ import { DashboardTabs } from "./dashboard-tabs";
export const DashboardShell = ({
children,
- onYieldCategorySelect,
pendingYieldCategory,
}: {
readonly children: ReactNode;
- readonly onYieldCategorySelect: (category: YieldCategory) => void;
readonly pendingYieldCategory: YieldCategory | null;
}) => {
const variant = useWidgetConfig("variant");
@@ -29,10 +27,7 @@ export const DashboardShell = ({
className={combineRecipeWithVariant({ rec: dashboardWrapper, variant })}
>
-
+
- Match.value(pathname).pipe(
- Match.when(startsWith("/activity"), () => "activity" as const),
- Match.when(startsWith("/borrow"), () => "borrow" as const),
- Match.when(startsWith("/positions"), () => "manage" as const),
- Match.orElse(() => "earn" as const)
- );
+export const resolveDashboardRouteTab = (pathname: string): RouteTab => {
+ if (Option.isSome(widgetRoutes.activity.matchPrefix(pathname))) {
+ return "activity";
+ }
+ if (Option.isSome(widgetRoutes.borrow.matchPrefix(pathname))) {
+ return "borrow";
+ }
+ if (Option.isSome(widgetRoutes.positions.matchPrefix(pathname))) {
+ return "manage";
+ }
+ return "earn";
+};
-const tabsMap: Record = {
- activity: "/activity",
- borrow: "/borrow",
- earn: "/",
- manage: "/positions",
+const tabsMap: Record = {
+ activity: widgetRoutes.activity.build(),
+ borrow: widgetRoutes.borrow.build("Dashboard"),
+ earn: widgetRoutes.earn.build(),
+ manage: widgetRoutes.positions.build(),
};
export const DashboardTabs = ({
- onYieldCategorySelect,
pendingYieldCategory,
}: {
- /** Selects a yield category; applying it may wait for the page transition. */
- readonly onYieldCategorySelect: (category: YieldCategory) => void;
/** A pressed yield category not yet applied to the Earn selection. */
readonly pendingYieldCategory: YieldCategory | null;
}) => {
const trackEvent = useTrackEvent();
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
const { view: yieldSelection } = useEarnYieldSelection();
const availableYieldCategories = yieldSelection.availableCategories;
const selectedYieldCategory =
pendingYieldCategory ?? yieldSelection.selectedCategory;
- const location = useLocation();
-
- const selectedTab = resolveDashboardRouteTab(location.pathname);
+ const selectedTab = resolveDashboardRouteTab(useLocation().pathname);
const onRouteTabPress = (selected: RouteTab) => {
if (selectedTab === selected) return;
trackEvent("tabClicked", { selected });
- navigate(tabsMap[selected]);
+ navigation.push(tabsMap[selected]);
};
const onYieldCategoryPress = (category: YieldCategory) => {
@@ -65,8 +70,7 @@ export const DashboardTabs = ({
}
trackEvent("tabClicked", { selected: category });
- onYieldCategorySelect(category);
- navigate("/");
+ navigation.push(dashboardEarnRoute.build({ yieldCategory: category }));
};
const borrowEnabled = useWidgetConfig("borrowEnabled");
diff --git a/packages/widget/src/app/layouts/layout-app-container.tsx b/packages/widget/src/app/layouts/layout-app-container.tsx
new file mode 100644
index 000000000..e3ff079b5
--- /dev/null
+++ b/packages/widget/src/app/layouts/layout-app-container.tsx
@@ -0,0 +1,62 @@
+import type { PropsWithChildren } from "react";
+import {
+ AppContainer,
+ type PageTransition,
+} from "../../features/widget-shell/composition";
+import type { WidgetLayout } from "../../services/config/widget-config-model";
+import type { LayoutAttributeValue } from "../../shared/styles/theme/ids";
+
+const compactPageTransition = {
+ initial: { opacity: 0, scale: 1, translateY: "-15px" },
+ animate: {
+ opacity: 1,
+ translateY: 0,
+ transition: { duration: 0.5, delay: 0.1 },
+ },
+ exit: {
+ opacity: 0,
+ filter: "blur(8px)",
+ scale: 0.8,
+ transition: { duration: 0.4 },
+ },
+} satisfies PageTransition;
+
+const dashboardPageTransition = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1, transition: { duration: 0.4, delay: 0.05 } },
+ exit: { opacity: 0, filter: "blur(8px)", transition: { duration: 0.1 } },
+} satisfies PageTransition;
+
+/**
+ * How the shell presents each Layout: the Layout attribute that scopes
+ * Layout-specific styles, and the page enter and exit animation.
+ */
+const layoutPresentation: Readonly<
+ Record<
+ WidgetLayout,
+ {
+ readonly attribute: LayoutAttributeValue;
+ readonly pageTransition: PageTransition;
+ }
+ >
+> = {
+ Compact: { attribute: "compact", pageTransition: compactPageTransition },
+ Dashboard: {
+ attribute: "dashboard",
+ pageTransition: dashboardPageTransition,
+ },
+};
+
+/** The shell's app container presented for the given Layout. */
+export const LayoutAppContainer = ({
+ layout,
+ children,
+}: PropsWithChildren<{ readonly layout: WidgetLayout }>) => {
+ const { attribute, pageTransition } = layoutPresentation[layout];
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/widget/src/app/routes/model/initial-route.ts b/packages/widget/src/app/routes/model/initial-route.ts
index 14fdf29a5..365ff77bb 100644
--- a/packages/widget/src/app/routes/model/initial-route.ts
+++ b/packages/widget/src/app/routes/model/initial-route.ts
@@ -1,5 +1,8 @@
-import { Match } from "effect";
import type { WidgetLayout } from "../../../services/config/widget-config-model";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../services/navigation/widget-routes";
import type { InitTab } from "../../../services/wallet/init-params";
/**
@@ -14,16 +17,13 @@ export const resolveInitialRoutePath = ({
readonly borrowAvailable: boolean;
readonly layout: WidgetLayout;
readonly tab: InitTab;
-}): string =>
- Match.value({ borrowAvailable, layout, tab }).pipe(
- Match.when({ tab: "activity" }, () => "/activity"),
- Match.when(
- ({ tab }) => tab === "positions" || tab === "manage",
- () => "/positions"
- ),
- Match.when(
- { borrowAvailable: true, layout: "Dashboard", tab: "borrow" },
- () => "/borrow"
- ),
- Match.orElse(() => "/")
- );
+}): WidgetPath => {
+ if (tab === "activity") return widgetRoutes.activity.build();
+ if (tab === "positions" || tab === "manage") {
+ return widgetRoutes.positions.build();
+ }
+ if (tab === "borrow" && borrowAvailable && layout === "Dashboard") {
+ return widgetRoutes.borrow.build(layout);
+ }
+ return widgetRoutes.earn.build();
+};
diff --git a/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts b/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts
index f35815a8a..13119fbe8 100644
--- a/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts
+++ b/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts
@@ -1,3 +1,6 @@
+import { Option } from "effect";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+
export type TabResourcesPrefetchLandingTab =
| "activity"
| "borrow"
@@ -8,10 +11,14 @@ export type TabResourcesPrefetchLandingTab =
export const resolveTabResourcesPrefetchLanding = (
pathname: string
): TabResourcesPrefetchLandingTab => {
- if (pathname.startsWith("/activity")) return "activity";
- if (pathname.startsWith("/borrow")) return "borrow";
- if (pathname.startsWith("/positions")) return "manage";
- if (pathname === "/") return "earn";
+ if (Option.isSome(widgetRoutes.activity.matchPrefix(pathname))) {
+ return "activity";
+ }
+ if (Option.isSome(widgetRoutes.borrow.matchPrefix(pathname))) return "borrow";
+ if (Option.isSome(widgetRoutes.positions.matchPrefix(pathname))) {
+ return "manage";
+ }
+ if (Option.isSome(widgetRoutes.earn.match(pathname))) return "earn";
return "other";
};
diff --git a/packages/widget/src/app/routes/state/tab-resources-prefetch.ts b/packages/widget/src/app/routes/state/tab-resources-prefetch.ts
index e86d230b5..7c4e7c8ad 100644
--- a/packages/widget/src/app/routes/state/tab-resources-prefetch.ts
+++ b/packages/widget/src/app/routes/state/tab-resources-prefetch.ts
@@ -4,7 +4,7 @@ import { borrowLandingPrimaryReadyAtom } from "../../../features/borrow/index";
import { earnLandingPrimaryReadyAtom } from "../../../features/earn/index";
import { manageTabResourcesPrefetchAtom } from "../../../features/portfolio/index";
import { walletScopeAtom } from "../../../features/wallet/index";
-import { applicationRouterPathnameAtom } from "../../runtime/application-router";
+import { applicationRouterLocationAtom } from "../../runtime/application-router";
import {
resolveTabResourcesPrefetchLanding,
shouldWarmTabResources,
@@ -12,7 +12,7 @@ import {
export const tabResourcesPrefetchAtom = Atom.make((get) => {
const tab = resolveTabResourcesPrefetchLanding(
- get(applicationRouterPathnameAtom)
+ get(applicationRouterLocationAtom).pathname
);
if (
diff --git a/packages/widget/src/app/routes/ui/borrow-feature-route.tsx b/packages/widget/src/app/routes/ui/borrow-feature-route.tsx
index df2916241..179b05241 100644
--- a/packages/widget/src/app/routes/ui/borrow-feature-route.tsx
+++ b/packages/widget/src/app/routes/ui/borrow-feature-route.tsx
@@ -1,14 +1,16 @@
import type { ReactNode } from "react";
-import { Navigate, Outlet } from "react-router";
+import { Outlet } from "react-router";
+import { WidgetNavigate } from "../../../features/navigation/views";
import { useWidgetConfig } from "../../../features/widget-configuration/index";
+import type { WidgetPath } from "../../../services/navigation/widget-routes";
export const BorrowFeatureRoute = ({
fallbackPath,
}: {
- readonly fallbackPath: string;
+ readonly fallbackPath: WidgetPath;
}): ReactNode =>
useWidgetConfig("borrowEnabled") ? (
) : (
-
+
);
diff --git a/packages/widget/src/app/runtime/application-router.ts b/packages/widget/src/app/runtime/application-router.ts
index d0b79bace..5c877dd5a 100644
--- a/packages/widget/src/app/runtime/application-router.ts
+++ b/packages/widget/src/app/runtime/application-router.ts
@@ -1,7 +1,11 @@
import { Effect, Option, Stream } from "effect";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import * as Atom from "effect/reactivity/Atom";
-import { ApplicationRouter } from "../../services/navigation/application-router";
+import {
+ type ApplicationLocation,
+ ApplicationRouter,
+} from "../../services/navigation/application-router";
+import { widgetRoutes } from "../../services/navigation/widget-routes";
import { applicationBaseRuntime } from "./application-base-runtime";
export const applicationRouterAtom = applicationBaseRuntime
@@ -15,18 +19,20 @@ export const applicationRouterAtom = applicationBaseRuntime
Atom.withLabel("applicationRouterAtom")
);
-export const applicationRouterPathnameAtom = applicationBaseRuntime
+export const applicationRouterLocationAtom = applicationBaseRuntime
.atom(
ApplicationRouter.use((applicationRouter) =>
- Effect.succeed(applicationRouter.pathnames)
+ Effect.succeed(applicationRouter.locations)
).pipe(Stream.unwrap)
)
.pipe(
Atom.map((result) =>
result.pipe(
AsyncResult.value,
- Option.getOrElse(() => "/")
+ Option.getOrElse(
+ (): ApplicationLocation => ({ pathname: widgetRoutes.earn.build() })
+ )
)
),
- Atom.withLabel("applicationRouterPathnameAtom")
+ Atom.withLabel("applicationRouterLocationAtom")
);
diff --git a/packages/widget/src/app/runtime/deep-link-coordinator.ts b/packages/widget/src/app/runtime/deep-link-coordinator.ts
index afaed4818..622227d6c 100644
--- a/packages/widget/src/app/runtime/deep-link-coordinator.ts
+++ b/packages/widget/src/app/runtime/deep-link-coordinator.ts
@@ -1,16 +1,17 @@
import { Context, Effect, Layer, Ref, Stream } from "effect";
+import type { YieldPendingActionType } from "../../domain/action/pending-action";
+import type { YieldId } from "../../domain/identity/identifiers";
import {
sameWalletScopeOwner,
type WalletScopeKey,
walletScopeOwnerKey,
} from "../../domain/wallet/wallet-scope";
-import { getPositionDetailsHubPath } from "../../features/position-details/index";
import { makeStartYieldTransactionFlow } from "../../features/yield-transaction-flow/runtime";
import {
- toWidgetPath,
WidgetNavigation,
type WidgetNavigationError,
} from "../../services/navigation/widget-navigation";
+import { widgetRoutes } from "../../services/navigation/widget-routes";
import type { WalletRuntimeInvariantError } from "../../services/wallet/wallet-errors";
import { walletScopeFromState } from "../../services/wallet/wallet-scope-adapter";
import { WalletService } from "../../services/wallet/wallet-service";
@@ -33,9 +34,9 @@ export type PendingActionDeepLinkObservation =
readonly _tag: "OpenValidatorSelection";
readonly balanceId: string;
readonly intent: PendingActionIntent;
- readonly pendingActionType: string;
+ readonly pendingActionType: YieldPendingActionType;
readonly walletScope: WalletScopeKey;
- readonly yieldId: string;
+ readonly yieldId: YieldId;
}>
| Readonly<{
readonly _tag: "StartYieldFlow";
@@ -49,7 +50,7 @@ export type DeepLinkRouteObservation = Readonly<{
readonly pendingAction: PendingActionDeepLinkObservation | null;
readonly position: {
readonly balanceId: string;
- readonly yieldId: string;
+ readonly yieldId: YieldId;
} | null;
readonly ready: boolean;
}>;
@@ -163,13 +164,16 @@ export class DeepLinkCoordinator extends Context.Service<
const openPendingAction = (
pendingAction: PendingActionDeepLinkObservation
) => {
+ // Both Layouts mount position details and its validator selection.
if (pendingAction._tag === "OpenValidatorSelection") {
return navigation
.execute({
_tag: "Push",
- path: toWidgetPath(
- `/positions/${pendingAction.yieldId}/${pendingAction.balanceId}/select-validator/${pendingAction.pendingActionType}`
- ),
+ to: widgetRoutes.positionSelectValidator.build({
+ balanceId: pendingAction.balanceId,
+ integrationId: pendingAction.yieldId,
+ pendingActionType: pendingAction.pendingActionType,
+ }),
})
.pipe(Effect.as(true));
}
@@ -191,12 +195,10 @@ export class DeepLinkCoordinator extends Context.Service<
navigation
.execute({
_tag: "Push",
- path: toWidgetPath(
- getPositionDetailsHubPath({
- balanceId: position.balanceId,
- integrationId: position.yieldId,
- })
- ),
+ to: widgetRoutes.positionDetails.build({
+ balanceId: position.balanceId,
+ integrationId: position.yieldId,
+ }),
})
.pipe(Effect.as(true))
);
diff --git a/packages/widget/src/domain/action/pending-action.ts b/packages/widget/src/domain/action/pending-action.ts
index 246747693..8b97d0568 100644
--- a/packages/widget/src/domain/action/pending-action.ts
+++ b/packages/widget/src/domain/action/pending-action.ts
@@ -1,10 +1,13 @@
import type BigNumber from "bignumber.js";
+import { Schema } from "effect";
import type { YieldBalanceType } from "../portfolio/positions";
-import type { ManageActionCommand, PendingAction } from "./models";
+import { ManageActionCommand, PendingAction } from "./models";
-export type YieldPendingActionType =
- | PendingAction["type"]
- | NonNullable;
+export const YieldPendingActionType = Schema.Union([
+ PendingAction.fields.type,
+ ManageActionCommand.fields.action,
+]);
+export type YieldPendingActionType = typeof YieldPendingActionType.Type;
/**
* Pending actions a position never needs: cancelling a queued request, or
diff --git a/packages/widget/src/features/activity/model/activity-action.ts b/packages/widget/src/features/activity/model/activity-action.ts
index 7cc99bf7f..1e6796177 100644
--- a/packages/widget/src/features/activity/model/activity-action.ts
+++ b/packages/widget/src/features/activity/model/activity-action.ts
@@ -11,6 +11,10 @@ import type {
} from "../../../domain/earn/models";
import type { ActionId } from "../../../domain/identity/identifiers";
import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../services/navigation/widget-routes";
export type YieldActivityItem = {
readonly _tag: "yield";
@@ -53,7 +57,9 @@ export const isActivitySelectionTarget = (
item._tag === target.source &&
item.actionData.id === target.actionId;
-export const getActivityActionPath = (target: ActivitySelectionTarget) =>
+export const getActivityActionPath = (
+ target: ActivitySelectionTarget
+): WidgetPath =>
target.source === "borrow"
- ? `/activity/borrow/${encodeURIComponent(target.actionId)}`
- : `/activity/${encodeURIComponent(target.actionId)}`;
+ ? widgetRoutes.activityBorrowAction.build({ actionId: target.actionId })
+ : widgetRoutes.activityAction.build({ actionId: target.actionId });
diff --git a/packages/widget/src/features/activity/react/activity-action-route.tsx b/packages/widget/src/features/activity/react/activity-action-route.tsx
index 6fc644b83..16a48668b 100644
--- a/packages/widget/src/features/activity/react/activity-action-route.tsx
+++ b/packages/widget/src/features/activity/react/activity-action-route.tsx
@@ -1,7 +1,16 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { createContext, useContext, useState } from "react";
-import { Navigate, Outlet, useMatch, useParams } from "react-router";
+import { Outlet } from "react-router";
+import {
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../services/navigation/widget-routes";
import { ContentLoaderSquare } from "../../../shared/ui/primitives/content-loader";
+import {
+ useWidgetRouteMatch,
+ useWidgetRoutePrefixMatch,
+} from "../../navigation/index";
+import { WidgetNavigate } from "../../navigation/views";
import { walletScopeAtom } from "../../wallet/index";
import type { YieldSummaryProvider } from "../../yield-summary/index";
import { YieldActionContinuationSessionRoute } from "../../yield-transaction-flow/views";
@@ -48,9 +57,19 @@ export const ActivityActionRoute = ({
readonly showsBackButton: boolean;
readonly source: ActivitySelectionTarget["source"];
}) => {
- const { actionId: actionIdParam } = useParams();
- const stepsMatch = useMatch("/activity/:actionId/steps");
- const completeMatch = useMatch("/activity/:actionId/complete");
+ const borrowActionId = useWidgetRoutePrefixMatch(
+ widgetRoutes.activityBorrowAction
+ )?.actionId;
+ const yieldActionId = useWidgetRoutePrefixMatch(
+ widgetRoutes.activityAction
+ )?.actionId;
+ const actionIdParam = source === "borrow" ? borrowActionId : yieldActionId;
+ const stepsMatch = useWidgetRouteMatch(
+ widgetYieldFlowRoutes.YieldActionContinuation.steps
+ );
+ const completeMatch = useWidgetRouteMatch(
+ widgetYieldFlowRoutes.YieldActionContinuation.complete
+ );
const executionMatch = stepsMatch ?? completeMatch;
const scope = useAtomValue(walletScopeAtom);
const parsed = parseActivityRouteIntent({
@@ -61,7 +80,7 @@ export const ActivityActionRoute = ({
if (!scope) return ;
if (parsed.status === "missing" || parsed.status === "invalid") {
- return ;
+ return ;
}
return (
@@ -102,7 +121,7 @@ const BoundActivityActionRoute = ({
if (result.status === "unavailable") {
return resolveUnavailableActivitySelection(selectionKey.intent) ===
"clear-route" ? (
-
+
) : null;
}
@@ -112,7 +131,7 @@ const BoundActivityActionRoute = ({
// Only the explicit action route spans Review, Steps, and Complete, so it is
// the one that owns a continuation.
if (result.canContinue && selectionKey.intent._tag === "default") {
- return ;
+ return ;
}
// A continuation started on this action's Review stays mounted through its
// execution; any other action drops it.
@@ -124,7 +143,7 @@ const BoundActivityActionRoute = ({
}
const continuationReady = continuedActionId === actionId;
if (selectionKey.surface === "execution" && !continuationReady) {
- return ;
+ return ;
}
return (
diff --git a/packages/widget/src/features/activity/react/use-activity-page.ts b/packages/widget/src/features/activity/react/use-activity-page.ts
index d75e09074..7ab8fc9c0 100644
--- a/packages/widget/src/features/activity/react/use-activity-page.ts
+++ b/packages/widget/src/features/activity/react/use-activity-page.ts
@@ -1,5 +1,9 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
-import { useMatch, useNavigate, useParams } from "react-router";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+import {
+ useWidgetNavigate,
+ useWidgetRoutePrefixMatch,
+} from "../../navigation/index";
import {
type ActivityActionItem,
getActivityActionPath,
@@ -21,15 +25,17 @@ export const useActivityPage = ({
}: {
readonly allowDefaultSelection?: boolean;
} = {}) => {
- const navigate = useNavigate();
- const { actionId: actionIdParam } = useParams();
- const borrowMatch = useMatch("/activity/borrow/:actionId");
+ const navigation = useWidgetNavigate();
+ const borrowMatch = useWidgetRoutePrefixMatch(
+ widgetRoutes.activityBorrowAction
+ );
+ const yieldMatch = useWidgetRoutePrefixMatch(widgetRoutes.activityAction);
const view = useAtomValue(activityPageViewAtom);
const loadMore = useAtomSet(loadMoreActivityAtom);
const retry = useAtomSet(retryActivityPageAtom);
const selectFilter = useAtomSet(setActivityPageFilterAtom);
const parsed = parseActivityRouteIntent({
- actionIdParam,
+ actionIdParam: borrowMatch?.actionId ?? yieldMatch?.actionId,
allowDefault: allowDefaultSelection,
source: borrowMatch ? "borrow" : "yield",
});
@@ -42,7 +48,7 @@ export const useActivityPage = ({
view,
selectedAction,
onActionSelect: (item: ActivityActionItem) =>
- navigate(getActivityActionPath(getActivitySelectionTarget(item))),
+ navigation.push(getActivityActionPath(getActivitySelectionTarget(item))),
onFilterSelect: selectFilter,
onLoadMore: () => loadMore(undefined),
onRetry: () => retry(undefined),
diff --git a/packages/widget/src/features/activity/routes.tsx b/packages/widget/src/features/activity/routes.tsx
index b3bc7c9e1..c1a832ef6 100644
--- a/packages/widget/src/features/activity/routes.tsx
+++ b/packages/widget/src/features/activity/routes.tsx
@@ -1,5 +1,10 @@
import type { ComponentType } from "react";
import { Route } from "react-router";
+import {
+ type WidgetPath,
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../services/navigation/widget-routes";
import {
YieldActionContinuationCompletePage,
YieldActionContinuationExecution,
@@ -8,13 +13,18 @@ import {
import { ActivityActionRoute } from "./react/activity-action-route";
import { ActivityDetailsPage } from "./ui/activity-details/activity-details.page";
+const continuationRoutes = widgetYieldFlowRoutes.YieldActionContinuation;
+
const activityActionChildRoutes = (
<>
} />
}>
- } />
}
+ />
+ }
/>
@@ -27,7 +37,7 @@ export const createActivityActionRoutes = ({
showsBackButton,
}: {
readonly ActionScopeGuard?: ComponentType<{
- readonly fallbackPath: string;
+ readonly fallbackPath: WidgetPath;
}>;
/** Whether the bare Activity route selects a default action. */
readonly selectsDefaultAction: boolean;
@@ -43,10 +53,16 @@ export const createActivityActionRoutes = ({
);
const actionIdRoutes = (
<>
-
+
} />
-
+
{activityActionChildRoutes}
>
@@ -60,7 +76,11 @@ export const createActivityActionRoutes = ({
) : null}
{ActionScopeGuard ? (
- }>
+
+ }
+ >
{actionIdRoutes}
) : (
diff --git a/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx b/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx
index a278831c8..b0fc5c61d 100644
--- a/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx
+++ b/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx
@@ -3,8 +3,8 @@ import clsx from "clsx";
import { capitalize, toLowerCase } from "effect/String";
import { type ReactNode, useId } from "react";
import { Trans, useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
import { isEthenaUsdeStaking } from "../../../../domain/earn/yield";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import { presentationClockAtom } from "../../../../shared/effect/presentation-clock";
import { borrowTokenToAppToken } from "../../../../shared/lib/borrow-token";
import {
@@ -23,6 +23,7 @@ import { ClockClockWiseIcon } from "../../../../shared/ui/primitives/icons/clock
import { ExternalLinkIcon } from "../../../../shared/ui/primitives/icons/external-link";
import { XIcon } from "../../../../shared/ui/primitives/icons/x-icon";
import { Text } from "../../../../shared/ui/primitives/typography/text";
+import { useWidgetNavigate } from "../../../navigation/index";
import { useTrackEvent, useTrackPage } from "../../../tracking/index";
import { PageCtaButton } from "../../../widget-shell/views";
import {
@@ -317,7 +318,7 @@ const Receipt = ({
const { t } = useTranslation();
const trackEvent = useTrackEvent();
const { showsBackButton } = useActivityActionRoute();
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
const title = useActivityActionTitle(view.title);
const network = capitalizeFirstLetters(view.network.replaceAll("-", " "));
@@ -333,7 +334,7 @@ const Receipt = ({
as="button"
aria-label={t("activity.details.back")}
className={styles.back}
- onClick={() => navigate("/activity")}
+ onClick={() => navigation.push(widgetRoutes.activity.build())}
>
diff --git a/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx
index 642c802df..4fb641439 100644
--- a/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx
+++ b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx
@@ -1,6 +1,8 @@
import { useAtomValue } from "@effect/atom-react";
-import { Outlet, useMatch } from "react-router";
+import { Outlet } from "react-router";
+import { widgetYieldFlowRoutes } from "../../../../../services/navigation/widget-routes";
import { Box } from "../../../../../shared/ui/primitives/box";
+import { useWidgetRouteMatch } from "../../../../navigation/index";
import { useSKWallet } from "../../../../wallet/index";
import {
AnimationPage,
@@ -13,8 +15,12 @@ import * as styles from "./styles.css";
export const ActivityTabPage = () => {
const wallet = useSKWallet();
const view = useAtomValue(activityPageViewAtom);
- const stepsMatch = useMatch("/activity/:actionId/steps");
- const completeMatch = useMatch("/activity/:actionId/complete");
+ const stepsMatch = useWidgetRouteMatch(
+ widgetYieldFlowRoutes.YieldActionContinuation.steps
+ );
+ const completeMatch = useWidgetRouteMatch(
+ widgetYieldFlowRoutes.YieldActionContinuation.complete
+ );
const isExecution = stepsMatch !== null || completeMatch !== null;
const showSplit = wallet?.status === "connected" && hasActivityActions(view);
diff --git a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts
index f24c75c85..d1cf10c19 100644
--- a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts
+++ b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts
@@ -1,4 +1,4 @@
-import { Equal, Option, Ref, Schema } from "effect";
+import { Equal, Ref, Schema } from "effect";
import type { CollateralToken } from "../../../domain/borrow/catalog/collateral-token";
import type { Integration } from "../../../domain/borrow/catalog/integration";
import type { Market } from "../../../domain/borrow/catalog/market";
@@ -7,6 +7,7 @@ import type {
ActionMetadata,
} from "../../../domain/borrow/execution/action";
import type { ActionCommand } from "../../../domain/borrow/execution/action-command";
+import type { MarketId } from "../../../domain/borrow/ids";
import type { BorrowNetwork } from "../../../domain/borrow/network";
import type {
RiskGateBlockReason,
@@ -16,9 +17,11 @@ import type {
import { exactDecimal } from "../../../domain/finance/exact";
import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope";
import {
- toWidgetPath,
- type WidgetPathInput,
-} from "../../../services/navigation/widget-navigation";
+ type WidgetFlowDestination,
+ type WidgetPath,
+ widgetBorrowFlowRoutes,
+ widgetRoutes,
+} from "../../../services/navigation/widget-routes";
export type BorrowConstraintWarning =
| "AmountExceedsAvailableLiquidity"
@@ -361,7 +364,7 @@ export type BorrowEntryFlowDetails = {
export type BorrowTransactionFlowEntry =
| { readonly _tag: "BorrowEntry"; readonly details: BorrowEntryFlowDetails }
- | { readonly _tag: "MarketPosition"; readonly marketId: string };
+ | { readonly _tag: "MarketPosition"; readonly marketId: MarketId };
export type BorrowTransactionFlowIntake = BorrowTransactionFlowReview & {
readonly entry: BorrowTransactionFlowEntry;
@@ -404,30 +407,20 @@ const BorrowFlowNavigationState = Schema.Struct({
executionActionId: Schema.optional(Schema.String),
});
-export const makeBorrowFlowNavigationState = (
- session: BorrowFlowSession,
- executionActionId?: string
-): typeof BorrowFlowNavigationState.Type =>
- executionActionId === undefined
- ? { borrowFlowSession: session }
- : { borrowFlowSession: session, executionActionId };
-
-export const decodeBorrowFlowNavigationState = (
- state: unknown
-): Option.Option =>
- Schema.decodeUnknownOption(BorrowFlowNavigationState)(state).pipe(
- Option.map((decoded) => decoded.borrowFlowSession)
- );
+type BorrowFlowNavigationState = typeof BorrowFlowNavigationState.Type;
-export const decodeBorrowFlowExecutionActionId = (
- state: unknown
-): string | null =>
- Schema.decodeUnknownOption(BorrowFlowNavigationState)(state).pipe(
- Option.flatMap((decoded) =>
- Option.fromUndefinedOr(decoded.executionActionId)
- ),
- Option.getOrNull
- );
+/**
+ * Each entry's Review, Steps, and Complete routes, carrying the Session (and
+ * on Steps and Complete, the handed-off action) in their navigation state.
+ */
+export const borrowFlowRoutes = {
+ BorrowEntry: widgetBorrowFlowRoutes.BorrowEntry.withState(
+ BorrowFlowNavigationState
+ ),
+ MarketPosition: widgetBorrowFlowRoutes.MarketPosition.withState(
+ BorrowFlowNavigationState
+ ),
+} as const satisfies Record;
export const getBorrowReviewTrackingProperties = (
intake: BorrowTransactionFlowIntake
@@ -447,18 +440,24 @@ export const getBorrowReviewTrackingProperties = (
};
};
-export const getBorrowTransactionFlowRoutes = (
+/**
+ * The flow pages an entry mounts, and its base: the page the flow starts from
+ * and leaves to. Only the Dashboard Layout mounts Borrow.
+ */
+export const getBorrowTransactionFlowDestination = (
entry: BorrowTransactionFlowEntry
-) => {
- const basePath: WidgetPathInput =
- entry._tag === "BorrowEntry"
- ? "/borrow"
- : `/positions/borrow/${entry.marketId}`;
-
- return {
- basePath: toWidgetPath(basePath),
- completePath: toWidgetPath(`${basePath}/complete`),
- reviewPath: toWidgetPath(`${basePath}/review`),
- stepsPath: toWidgetPath(`${basePath}/steps`),
- } as const;
-};
+): WidgetFlowDestination &
+ Readonly<{ readonly basePath: WidgetPath }> =>
+ entry._tag === "BorrowEntry"
+ ? {
+ ...borrowFlowRoutes.BorrowEntry.at("Dashboard"),
+ basePath: widgetRoutes.borrow.build("Dashboard"),
+ }
+ : {
+ ...borrowFlowRoutes.MarketPosition.at("Dashboard", {
+ marketId: entry.marketId,
+ }),
+ basePath: widgetRoutes.borrowPosition.build("Dashboard", {
+ marketId: entry.marketId,
+ }),
+ };
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 1408778af..c581394fd 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,5 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
-import { Option, Schema } from "effect";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import {
createContext,
@@ -7,19 +6,21 @@ import {
useContext,
useState,
} from "react";
-import { Navigate, Outlet, useLocation, useParams } from "react-router";
-import {
- type MarketId,
- MarketId as MarketIdSchema,
-} from "../../../domain/borrow/ids";
+import { Outlet, useLocation } from "react-router";
+import type { MarketId } from "../../../domain/borrow/ids";
import { sameWalletScopeOwner } from "../../../domain/wallet/wallet-scope";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+import {
+ useWidgetRoutePrefixMatch,
+ useWidgetRouteState,
+} from "../../navigation/index";
+import { WidgetNavigate } from "../../navigation/views";
import { walletScopeAtom } from "../../wallet/index";
import {
type BorrowFlowSession,
type BorrowTransactionFlowEntry,
- decodeBorrowFlowExecutionActionId,
- decodeBorrowFlowNavigationState,
- getBorrowTransactionFlowRoutes,
+ borrowFlowRoutes,
+ getBorrowTransactionFlowDestination,
} from "../model/borrow-transaction-flow";
import {
BorrowFlowExecutionKey,
@@ -49,9 +50,18 @@ export const useBorrowTransactionFlowReview = () =>
)
);
+/** The action Steps and Complete were navigated to run, if any. */
+const useBorrowFlowExecutionActionId = () => {
+ const { intake } = useBorrowTransactionFlow();
+ return (
+ useWidgetRouteState(borrowFlowRoutes[intake.entry._tag])
+ ?.executionActionId ?? null
+ );
+};
+
/** The Execution of the action Steps and Complete were navigated to run. */
export const useBorrowTransactionFlowExecution = () => {
- const actionId = decodeBorrowFlowExecutionActionId(useLocation().state);
+ const actionId = useBorrowFlowExecutionActionId();
const session = useBorrowTransactionFlow();
if (!actionId) throw new Error("Borrow Flow Execution is unavailable.");
return useAtomValue(
@@ -71,9 +81,13 @@ const getEntryFallbackPath = (
expected: BorrowTransactionFlowEntry["_tag"],
marketId: MarketId | undefined
) => {
- if (expected === "BorrowEntry") return "/borrow";
- if (marketId) return `/positions/borrow/${marketId}`;
- return "/positions";
+ if (expected === "BorrowEntry") {
+ return widgetRoutes.borrow.build("Dashboard");
+ }
+ if (marketId) {
+ return widgetRoutes.borrowPosition.build("Dashboard", { marketId });
+ }
+ return widgetRoutes.positions.build();
};
type MountedBorrowFlowSession = Readonly<{
@@ -92,14 +106,12 @@ export const BorrowTransactionFlowRoute = ({
}: {
readonly expected: BorrowTransactionFlowEntry["_tag"];
}) => {
- const routeParams = useParams();
- const marketId = routeParams.marketId
- ? Schema.decodeSync(MarketIdSchema)(routeParams.marketId)
- : undefined;
+ const marketId = useWidgetRoutePrefixMatch(
+ widgetRoutes.borrowPosition
+ )?.marketId;
const location = useLocation();
- const navigated = Option.getOrNull(
- decodeBorrowFlowNavigationState(location.state)
- );
+ const navigated =
+ useWidgetRouteState(borrowFlowRoutes[expected])?.borrowFlowSession ?? null;
const [mounted, setMounted] = useState(null);
if (navigated && navigated !== mounted?.session) {
setMounted({ key: location.key, session: navigated });
@@ -109,7 +121,9 @@ export const BorrowTransactionFlowRoute = ({
!mounted ||
!matchesEntry(mounted.session.intake.entry, expected, marketId)
) {
- return ;
+ return (
+
+ );
}
return ;
};
@@ -123,9 +137,9 @@ const BorrowFlowSessionRoute = ({
const walletScope = useAtomValue(walletScopeAtom);
if (!walletScope || !sameWalletScopeOwner(walletScope, session.walletScope)) {
return (
-
);
}
@@ -144,12 +158,12 @@ export const BorrowTransactionFlowExecutionRoute = ({
children,
}: PropsWithChildren) => {
const { intake } = useBorrowTransactionFlow();
- const actionId = decodeBorrowFlowExecutionActionId(useLocation().state);
+ const actionId = useBorrowFlowExecutionActionId();
if (!actionId) {
return (
-
);
}
@@ -163,9 +177,9 @@ const ExecutionStarted = ({ children }: PropsWithChildren) => {
);
if (workflow._tag === "Failure") {
return (
-
);
}
@@ -178,19 +192,23 @@ export const BorrowTransactionFlowExecutionPage = ({
children,
page,
}: PropsWithChildren<{ readonly page: "Complete" | "Steps" }>) => {
- const { intake } = useBorrowTransactionFlow();
- const location = useLocation();
+ const session = useBorrowTransactionFlow();
+ const actionId = useBorrowFlowExecutionActionId();
const state = useAtomValue(useBorrowTransactionFlowExecution().stateAtom);
if (!AsyncResult.isSuccess(state)) return null;
- const { completePath, stepsPath } = getBorrowTransactionFlowRoutes(
- intake.entry
- );
+ const destination = getBorrowTransactionFlowDestination(session.intake.entry);
+ const navigationState = {
+ borrowFlowSession: session,
+ ...(actionId ? { executionActionId: actionId } : {}),
+ };
const completed = state.value._tag === "Completed";
if (page === "Steps" && completed) {
- return ;
+ return (
+
+ );
}
if (page === "Complete" && !completed) {
- return ;
+ return ;
}
return children ?? ;
};
diff --git a/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts b/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts
index fe6715ca0..1e7399bfc 100644
--- a/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts
+++ b/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts
@@ -14,8 +14,7 @@ import {
BorrowFlowSession,
type BorrowTransactionFlowIntake,
getBorrowReviewTrackingProperties,
- getBorrowTransactionFlowRoutes,
- makeBorrowFlowNavigationState,
+ getBorrowTransactionFlowDestination,
} from "../model/borrow-transaction-flow";
/** Validates the intake and navigates to Review carrying a new Session. */
@@ -48,8 +47,9 @@ export const startBorrowTransactionFlow = Effect.fn(
yield* Effect.uninterruptible(
navigation.execute({
_tag: "Push",
- path: getBorrowTransactionFlowRoutes(session.intake.entry).reviewPath,
- state: makeBorrowFlowNavigationState(session),
+ to: getBorrowTransactionFlowDestination(session.intake.entry).review({
+ borrowFlowSession: session,
+ }),
})
);
const trackingProperties = getBorrowReviewTrackingProperties(session.intake);
@@ -75,7 +75,7 @@ export const leaveBorrowFlow = (session: BorrowFlowSession) =>
Effect.uninterruptible(
navigation.execute({
_tag: "Replace",
- path: getBorrowTransactionFlowRoutes(session.intake.entry).basePath,
+ to: getBorrowTransactionFlowDestination(session.intake.entry).basePath,
})
)
);
diff --git a/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts b/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts
index a6ff60560..f51613f1a 100644
--- a/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts
+++ b/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts
@@ -14,8 +14,7 @@ import {
} from "../../../services/navigation/widget-navigation";
import {
type BorrowFlowSession,
- getBorrowTransactionFlowRoutes,
- makeBorrowFlowNavigationState,
+ getBorrowTransactionFlowDestination,
} from "../model/borrow-transaction-flow";
import { leaveBorrowFlow } from "./borrow-flow-session";
@@ -121,7 +120,7 @@ export const confirmBorrowAction = Effect.fn("confirmBorrowAction")(function* (
}
const navigation = yield* WidgetNavigation;
- const { stepsPath } = getBorrowTransactionFlowRoutes(session.intake.entry);
+ const destination = getBorrowTransactionFlowDestination(session.intake.entry);
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const held = yield* Ref.getAndUpdate(
@@ -133,8 +132,10 @@ export const confirmBorrowAction = Effect.fn("confirmBorrowAction")(function* (
yield* navigation
.execute({
_tag: "Push",
- path: stepsPath,
- state: makeBorrowFlowNavigationState(session, action.id),
+ to: destination.steps({
+ borrowFlowSession: session,
+ executionActionId: action.id,
+ }),
})
.pipe(
Effect.tapError(() =>
diff --git a/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx b/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx
index 74f3e614e..61b2e1569 100644
--- a/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx
+++ b/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx
@@ -1,5 +1,6 @@
import type { ReactElement } from "react";
import { Route } from "react-router";
+import { widgetBorrowFlowRoutes } from "../../../services/navigation/widget-routes";
import type { BorrowTransactionFlowEntry } from "../model/borrow-transaction-flow";
import {
BorrowTransactionFlowExecutionPage,
@@ -19,35 +20,42 @@ type BorrowTransactionFlowRouteMount = Readonly<{
readonly layout?: ReactElement;
}>;
-const flowPages = (
- <>
- } />
- }>
-
-
-
- }
- />
-
-
-
- }
- />
-
- >
-);
+const flowPages = (entry: BorrowTransactionFlowEntry["_tag"]) => {
+ const routes = widgetBorrowFlowRoutes[entry];
+ return (
+ <>
+ } />
+ }>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ >
+ );
+};
export const createBorrowTransactionFlowRoutes = ({
entry,
layout,
}: BorrowTransactionFlowRouteMount) => (
}>
- {layout ? {flowPages} : flowPages}
+ {layout ? (
+ {flowPages(entry)}
+ ) : (
+ flowPages(entry)
+ )}
);
diff --git a/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx b/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx
index d2d9912bb..92f47a482 100644
--- a/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx
+++ b/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx
@@ -17,7 +17,7 @@ import {
PageContainer,
PageCtaButton,
} from "../../widget-shell/views";
-import { getBorrowTransactionFlowRoutes } from "../model/borrow-transaction-flow";
+import { getBorrowTransactionFlowDestination } from "../model/borrow-transaction-flow";
import {
useBorrowTransactionFlow,
useBorrowTransactionFlowExecution,
@@ -34,7 +34,7 @@ export const BorrowStepsPage = () => {
const executionFlow = useBorrowTransactionFlowExecution();
const back = useAtomSet(executionFlow.leaveAtom);
const leaveConfirmation = useExecutionLeaveConfirmation(
- getBorrowTransactionFlowRoutes(flow.intake.entry).completePath
+ getBorrowTransactionFlowDestination(flow.intake.entry).completePath
);
const leave = () => leaveConfirmation.confirm(() => back(undefined));
const execution = useBorrowExecution();
diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx
index b54bf3f6a..6a01eb3df 100644
--- a/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx
+++ b/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx
@@ -1,11 +1,16 @@
import type { ComponentType, ReactElement, ReactNode } from "react";
-import { Navigate, Route } from "react-router";
+import { Route } from "react-router";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../../services/navigation/widget-routes";
+import { WidgetNavigate } from "../../../navigation/views";
import { BorrowEntryLayout, BorrowFlowLayout, BorrowShell } from "./layout";
import { BorrowFormPage } from "./page";
type BorrowEntryRouteComposition = Readonly<{
BorrowConnectedWalletRoute: ComponentType;
- WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>;
+ WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>;
createBorrowTransactionFlowRoutes: (input: {
readonly entry: "BorrowEntry";
readonly layout: ReactElement;
@@ -16,21 +21,27 @@ export const createBorrowEntryRoutes = ({
BorrowConnectedWalletRoute,
WalletScopeRouteGuard,
createBorrowTransactionFlowRoutes,
-}: BorrowEntryRouteComposition) => (
- <>
- }>
- }>
- } />
-
- }>
- }>
- {createBorrowTransactionFlowRoutes({
- entry: "BorrowEntry",
- layout: ,
- })}
+}: BorrowEntryRouteComposition) => {
+ const borrowPath = widgetRoutes.borrow.build("Dashboard");
+ return (
+ <>
+ }>
+ }>
+ } />
+
+ }>
+ }>
+ {createBorrowTransactionFlowRoutes({
+ entry: "BorrowEntry",
+ layout: ,
+ })}
+
-
- } />
- >
-);
+ }
+ />
+ >
+ );
+};
diff --git a/packages/widget/src/features/borrow/composition.ts b/packages/widget/src/features/borrow/composition.ts
index 7478f634c..4ad7075a8 100644
--- a/packages/widget/src/features/borrow/composition.ts
+++ b/packages/widget/src/features/borrow/composition.ts
@@ -1,10 +1,11 @@
import type { ComponentType, ReactElement, ReactNode } from "react";
+import type { WidgetPath } from "../../services/navigation/widget-routes";
import { createBorrowEntryRoutes as createEntryRoutes } from "./borrow-entry/composition";
import { createBorrowMarketPositionRoutes as createMarketPositionRoutes } from "./market-position/composition";
import { BorrowConnectedWalletRoute } from "./wallet/composition";
type BorrowRouteComposition = Readonly<{
- WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>;
+ WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>;
createBorrowTransactionFlowRoutes: (input: {
readonly entry: "BorrowEntry" | "MarketPosition";
readonly layout?: ReactElement;
diff --git a/packages/widget/src/features/borrow/market-position/ui/action.page.tsx b/packages/widget/src/features/borrow/market-position/ui/action.page.tsx
index 90b5d1eec..f705dc577 100644
--- a/packages/widget/src/features/borrow/market-position/ui/action.page.tsx
+++ b/packages/widget/src/features/borrow/market-position/ui/action.page.tsx
@@ -1,14 +1,16 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { Navigate, useParams } from "react-router";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import { Box } from "../../../../shared/ui/primitives/box";
import { Text } from "../../../../shared/ui/primitives/typography/text";
+import { useWidgetRouteMatch } from "../../../navigation/index";
+import { WidgetNavigate } from "../../../navigation/views";
import {
type BorrowPositionAction,
isBorrowCollateralToggleAction,
} from "../model/details";
import { BorrowPositionBreadcrumb } from "./components/breadcrumb";
-import { getBorrowPositionBasePath, useBorrowPositionContext } from "./context";
+import { useBorrowPositionContext } from "./context";
import { RepayActionForm } from "./forms/repay-action-form";
import { WithdrawActionForm } from "./forms/withdraw-action-form";
@@ -31,7 +33,13 @@ const BorrowPositionActionForm = ({
};
export const BorrowPositionActionPage = () => {
- const { actionId, marketId } = useParams();
+ const match = useWidgetRouteMatch(widgetRoutes.borrowPositionAction);
+ const actionId = match?.actionId;
+ const positionPath = match
+ ? widgetRoutes.borrowPosition.build("Dashboard", {
+ marketId: match.marketId,
+ })
+ : widgetRoutes.positions.build();
const { t } = useTranslation();
const { actions, model, position } = useBorrowPositionContext();
const action = actions.find((candidate) => candidate.id === actionId);
@@ -45,7 +53,7 @@ export const BorrowPositionActionPage = () => {
}
if (isBorrowCollateralToggleAction(action)) {
- return ;
+ return ;
}
return ;
@@ -55,7 +63,7 @@ export const BorrowPositionActionPage = () => {
return (
<>
diff --git a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx
index c870d4b3d..85c785261 100644
--- a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx
+++ b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx
@@ -1,8 +1,12 @@
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import { Box } from "../../../../shared/ui/primitives/box";
import { Button } from "../../../../shared/ui/primitives/button";
import { Text } from "../../../../shared/ui/primitives/typography/text";
+import {
+ useWidgetNavigate,
+ useWidgetRouteMatch,
+} from "../../../navigation/index";
import {
type BorrowPositionAction,
isBorrowCollateralToggleAction,
@@ -13,7 +17,8 @@ import * as styles from "./styles.css";
export const BorrowPositionActionsPage = () => {
const { t } = useTranslation();
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
+ const marketId = useWidgetRouteMatch(widgetRoutes.borrowPosition)?.marketId;
const {
actions: positionActions,
model,
@@ -23,8 +28,15 @@ export const BorrowPositionActionsPage = () => {
(action) => !isBorrowCollateralToggleAction(action)
);
- const onActionSelect = (action: BorrowPositionAction) =>
- navigate(`action/${action.id}`);
+ const onActionSelect = (action: BorrowPositionAction) => {
+ if (!marketId) return;
+ navigation.push(
+ widgetRoutes.borrowPositionAction.build("Dashboard", {
+ actionId: action.id,
+ marketId,
+ })
+ );
+ };
return (
<>
diff --git a/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx b/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx
index 6daeccff4..439c28f88 100644
--- a/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx
+++ b/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx
@@ -1,17 +1,21 @@
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../../../services/navigation/widget-routes";
import { PositionDetailsBreadcrumb } from "../../../../../shared/ui/components/position-details";
+import { useWidgetNavigate } from "../../../../navigation/index";
import { BackButton, BackButtonProvider } from "../../../../widget-shell/views";
export const BorrowPositionBreadcrumb = ({
- backPath = "/positions",
+ backPath = widgetRoutes.positions.build(),
positionName,
}: {
- readonly backPath?: string;
+ readonly backPath?: WidgetPath;
readonly positionName: string | null;
}) => {
const { t } = useTranslation();
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
return (
@@ -19,7 +23,7 @@ export const BorrowPositionBreadcrumb = ({
backButton={
navigate(backPath)}
+ onClick={() => navigation.push(backPath)}
/>
}
positionName={positionName}
diff --git a/packages/widget/src/features/borrow/market-position/ui/context.ts b/packages/widget/src/features/borrow/market-position/ui/context.ts
index 83922faf3..9651050cf 100644
--- a/packages/widget/src/features/borrow/market-position/ui/context.ts
+++ b/packages/widget/src/features/borrow/market-position/ui/context.ts
@@ -15,6 +15,3 @@ export type BorrowPositionContext = {
export const useBorrowPositionContext = () =>
useOutletContext();
-
-export const getBorrowPositionBasePath = (marketId: string | undefined) =>
- marketId ? `/positions/borrow/${marketId}` : "/positions";
diff --git a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx
index bf7ac985b..8abe2dc2f 100644
--- a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx
+++ b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx
@@ -1,11 +1,17 @@
import { useAtomSet } from "@effect/atom-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { Navigate, Outlet, useLocation, useParams } from "react-router";
+import { Outlet } from "react-router";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import { toPresentable } from "../../../../shared/effect/async-result";
import { PositionDetailsPane } from "../../../../shared/ui/components/position-details";
import { Text } from "../../../../shared/ui/primitives/typography/text";
import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation";
+import {
+ useWidgetRouteMatch,
+ useWidgetRoutePrefixMatch,
+} from "../../../navigation/index";
+import { WidgetNavigate } from "../../../navigation/views";
import { useTrackPage } from "../../../tracking/index";
import {
AnimationPage,
@@ -28,16 +34,16 @@ import {
BorrowPositionActionsSkeleton,
BorrowPositionInfoSkeleton,
} from "./components/skeletons";
-import {
- type BorrowPositionContext,
- getBorrowPositionBasePath,
-} from "./context";
+import type { BorrowPositionContext } from "./context";
export const BorrowPositionDetailsPage = () => {
useTrackPage("positionDetails");
- const { marketId } = useParams();
- const location = useLocation();
+ const marketId = useWidgetRoutePrefixMatch(
+ widgetRoutes.borrowPosition
+ )?.marketId;
+ const hasNestedRoute =
+ useWidgetRouteMatch(widgetRoutes.borrowPosition) === null;
const { t } = useTranslation();
const { locale } = useWidgetPresentation();
const startPositionReview = useAtomSet(startBorrowPositionActionReviewAtom);
@@ -60,9 +66,6 @@ export const BorrowPositionDetailsPage = () => {
})
: [];
const isPositionLoading = presentablePosition._tag === "Loading";
- const positionBasePath = getBorrowPositionBasePath(marketId);
- const normalizedPathname = location.pathname.replace(/\/+$/u, "");
- const hasNestedRoute = normalizedPathname !== positionBasePath;
const shouldShowLeftPane = hasNestedRoute || actions.length > 0 || !!position;
const context: BorrowPositionContext = {
actions,
@@ -99,7 +102,7 @@ export const BorrowPositionDetailsPage = () => {
// A closed position (e.g. after a full repay) has nothing to manage; nested
// flow routes still render so Complete can show the settled result.
if (!hasNestedRoute && presentablePosition._tag === "Ready" && !position) {
- return ;
+ return ;
}
const rightContent = (() => {
diff --git a/packages/widget/src/features/borrow/market-position/ui/routes.tsx b/packages/widget/src/features/borrow/market-position/ui/routes.tsx
index ee1ee806c..94891c37b 100644
--- a/packages/widget/src/features/borrow/market-position/ui/routes.tsx
+++ b/packages/widget/src/features/borrow/market-position/ui/routes.tsx
@@ -1,12 +1,17 @@
import type { ComponentType, ReactNode } from "react";
-import { Navigate, Route } from "react-router";
+import { Route } from "react-router";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../../services/navigation/widget-routes";
+import { WidgetNavigate } from "../../../navigation/views";
import { BorrowPositionActionPage } from "./action.page.tsx";
import { BorrowPositionActionsPage } from "./actions.page.tsx";
import { BorrowPositionDetailsPage } from "./details.page.tsx";
type BorrowMarketPositionRouteComposition = Readonly<{
BorrowConnectedWalletRoute: ComponentType;
- WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>;
+ WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>;
createBorrowTransactionFlowRoutes: (input: {
readonly entry: "MarketPosition";
}) => ReactNode;
@@ -16,20 +21,29 @@ export const createBorrowMarketPositionRoutes = ({
BorrowConnectedWalletRoute,
WalletScopeRouteGuard,
createBorrowTransactionFlowRoutes,
-}: BorrowMarketPositionRouteComposition) => (
- <>
- }>
- }>
- }>
- } />
+}: BorrowMarketPositionRouteComposition) => {
+ const positionsPath = widgetRoutes.positions.build();
+ return (
+ <>
+ }>
+ }>
}
- />
- {createBorrowTransactionFlowRoutes({ entry: "MarketPosition" })}
+ path={widgetRoutes.borrowPosition.pattern}
+ element={}
+ >
+ } />
+ }
+ />
+ {createBorrowTransactionFlowRoutes({ entry: "MarketPosition" })}
+
-
- } />
- >
-);
+ }
+ />
+ >
+ );
+};
diff --git a/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx b/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx
index 6e43d9fc8..0d7661cb7 100644
--- a/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx
+++ b/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
-import { Navigate, Outlet } from "react-router";
+import { Outlet } from "react-router";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader";
+import { WidgetNavigate } from "../../../navigation/views";
import { useBorrowWalletView } from "../react/use-borrow-wallet";
export const BorrowConnectedWalletRoute = (): ReactNode => {
@@ -11,5 +13,5 @@ export const BorrowConnectedWalletRoute = (): ReactNode => {
if (walletView.status === "connecting") {
return ;
}
- return ;
+ return ;
};
diff --git a/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts b/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts
index 1fd0cc30c..b48c726fc 100644
--- a/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts
+++ b/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts
@@ -1,7 +1,10 @@
import { useAtomMount, useAtomValue } from "@effect/atom-react";
import { useRef, useState } from "react";
import { type Blocker, useBlocker } from "react-router";
-import type { WidgetPath } from "../../../services/navigation/widget-navigation";
+import {
+ isWidgetPath,
+ type WidgetPath,
+} from "../../../services/navigation/widget-routes";
import {
beforeUnloadLeaveGuardAtom,
leaveGuardedAtom,
@@ -73,7 +76,7 @@ export const useExecutionLeaveConfirmation = (completePath: WidgetPath) => {
guarded &&
!leaving.current &&
nextLocation.pathname !== currentLocation.pathname &&
- nextLocation.pathname !== completePath
+ !isWidgetPath(nextLocation.pathname, completePath)
);
// Each blocked attempt is a new Blocker; the same history entry is not.
const heldNavigation = blocker.state === "blocked" ? blocker : null;
diff --git a/packages/widget/src/features/navigation/index.ts b/packages/widget/src/features/navigation/index.ts
new file mode 100644
index 000000000..325596b69
--- /dev/null
+++ b/packages/widget/src/features/navigation/index.ts
@@ -0,0 +1,6 @@
+export {
+ useWidgetNavigate,
+ useWidgetRouteMatch,
+ useWidgetRoutePrefixMatch,
+ useWidgetRouteState,
+} from "./react/widget-navigation";
diff --git a/packages/widget/src/features/navigation/react/widget-navigation.ts b/packages/widget/src/features/navigation/react/widget-navigation.ts
new file mode 100644
index 000000000..9c7fe4682
--- /dev/null
+++ b/packages/widget/src/features/navigation/react/widget-navigation.ts
@@ -0,0 +1,46 @@
+import { Option } from "effect";
+import { useLocation, useNavigate } from "react-router";
+import {
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../services/navigation/widget-routes";
+
+type RouteMatcher = Readonly<{
+ readonly match: (pathname: string) => Option.Option;
+ readonly matchPrefix: (pathname: string) => Option.Option;
+}>;
+
+type RouteState = Readonly<{
+ readonly state: (locationState: unknown) => Option.Option;
+}>;
+
+/** Navigates to registry paths and locations from React. */
+export const useWidgetNavigate = () => {
+ const navigate = useNavigate();
+ return {
+ back: () => navigate(-1),
+ push: (to: WidgetNavigationTarget) =>
+ navigate(widgetTargetPath(to), { state: widgetTargetState(to) }),
+ replace: (to: WidgetNavigationTarget) =>
+ navigate(widgetTargetPath(to), {
+ replace: true,
+ state: widgetTargetState(to),
+ }),
+ } as const;
+};
+
+/** The route's params while the current location is exactly that route. */
+export const useWidgetRouteMatch = (
+ route: RouteMatcher
+): Params | null => Option.getOrNull(route.match(useLocation().pathname));
+
+/** The route's params while the current location is that route or inside it. */
+export const useWidgetRoutePrefixMatch = (
+ route: RouteMatcher
+): Params | null => Option.getOrNull(route.matchPrefix(useLocation().pathname));
+
+/** The current location's state, decoded as the route's bound state. */
+export const useWidgetRouteState = (
+ route: RouteState
+): State | null => Option.getOrNull(route.state(useLocation().state));
diff --git a/packages/widget/src/features/navigation/ui/widget-navigate.tsx b/packages/widget/src/features/navigation/ui/widget-navigate.tsx
new file mode 100644
index 000000000..31d695240
--- /dev/null
+++ b/packages/widget/src/features/navigation/ui/widget-navigate.tsx
@@ -0,0 +1,35 @@
+import type { PropsWithChildren } from "react";
+import { type LinkProps, Navigate } from "react-router";
+import {
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../services/navigation/widget-routes";
+import { SKLink } from "../../../shared/ui/primitives/link";
+
+/** Redirects to a registry path or location while rendered. */
+export const WidgetNavigate = ({
+ replace,
+ to,
+}: {
+ readonly replace?: boolean;
+ readonly to: WidgetNavigationTarget;
+}) => (
+
+);
+
+/** A link to a registry path or location. */
+export const WidgetLink = ({
+ to,
+ ...rest
+}: PropsWithChildren<
+ Omit & {
+ readonly to: WidgetNavigationTarget;
+ }
+>) => (
+
+);
diff --git a/packages/widget/src/features/navigation/views.ts b/packages/widget/src/features/navigation/views.ts
new file mode 100644
index 000000000..11093bf4c
--- /dev/null
+++ b/packages/widget/src/features/navigation/views.ts
@@ -0,0 +1 @@
+export { WidgetLink, WidgetNavigate } from "./ui/widget-navigate";
diff --git a/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx b/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx
index 0fd50099f..795263681 100644
--- a/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx
+++ b/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { isConfidentialYield } from "../../../../domain/earn/confidential";
import type { EarnYieldWithProvider } from "../../../../domain/earn/models";
import type { PositionDetailsLabelType } from "../../../../domain/portfolio/positions";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import {
formatTokenAmount,
formatUsd,
@@ -12,11 +13,11 @@ import { TokenIcon } from "../../../../shared/ui/components/token-icon";
import { ToolTip } from "../../../../shared/ui/components/tooltip";
import { Box } from "../../../../shared/ui/primitives/box";
import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader";
-import { SKLink } from "../../../../shared/ui/primitives/link";
import { ListItem } from "../../../../shared/ui/primitives/list/list-item";
import { Spinner } from "../../../../shared/ui/primitives/spinner";
import { Text } from "../../../../shared/ui/primitives/typography/text";
import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation";
+import { WidgetLink } from "../../../navigation/views";
import type { YieldSummaryProvider } from "../../../yield-summary/index";
import type { PositionItem } from "../../state/read-models/positions";
import {
@@ -147,9 +148,11 @@ export const EarnPositionListItem = ({
})();
return (
-
@@ -287,6 +290,6 @@ export const EarnPositionListItem = ({
)}
-
+
);
};
diff --git a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx
index 0cc7c7e3e..a05d74589 100644
--- a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx
+++ b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx
@@ -1,11 +1,12 @@
import { useTranslation } from "react-i18next";
import type { MarketPosition } from "../../../../../../domain/borrow/positions/market-position";
+import { widgetRoutes } from "../../../../../../services/navigation/widget-routes";
import { TokenIcon } from "../../../../../../shared/ui/components/token-icon";
import { Box } from "../../../../../../shared/ui/primitives/box";
-import { SKLink } from "../../../../../../shared/ui/primitives/link";
import { ListItem } from "../../../../../../shared/ui/primitives/list/list-item";
import { Text } from "../../../../../../shared/ui/primitives/typography/text";
import { useWidgetPresentation } from "../../../../../../shared/ui/widget-presentation";
+import { WidgetLink } from "../../../../../navigation/views";
import type { UnifiedPositionItem } from "../../../../state/read-models/positions";
import { EarnPositionListItem } from "../../../components/earn-position-list-item";
import {
@@ -29,7 +30,11 @@ const BorrowMarketPositionListItem = ({
const model = getBorrowMarketPositionListItemModel({ locale, position, t });
return (
-
+
-
+
);
};
diff --git a/packages/widget/src/features/position-details/index.ts b/packages/widget/src/features/position-details/index.ts
deleted file mode 100644
index aadf9800b..000000000
--- a/packages/widget/src/features/position-details/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { getPositionDetailsHubPath } from "./model/hub";
diff --git a/packages/widget/src/features/position-details/model/hub.ts b/packages/widget/src/features/position-details/model/hub.ts
index e8686c123..84635418e 100644
--- a/packages/widget/src/features/position-details/model/hub.ts
+++ b/packages/widget/src/features/position-details/model/hub.ts
@@ -5,14 +5,6 @@ export type PositionDetailsActionCapabilities = {
readonly canUnstake: boolean;
};
-export const getPositionDetailsHubPath = ({
- integrationId,
- balanceId,
-}: {
- integrationId: string | undefined;
- balanceId: string | undefined;
-}): `/${string}` => `/positions/${integrationId}/${balanceId}`;
-
export const resolvePositionDetailsActionMode = ({
canStake,
canUnstake,
diff --git a/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts b/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts
deleted file mode 100644
index 07d549b2e..000000000
--- a/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useMatch } from "react-router";
-
-export const usePendingActionSelectValidatorMatch = () =>
- useMatch(
- "positions/:integrationId/:balanceId/select-validator/:pendingActionType"
- );
diff --git a/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts b/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts
deleted file mode 100644
index bcc49bcad..000000000
--- a/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { useMatch } from "react-router";
-
-export const usePositionDetailsHubMatch = () =>
- useMatch("positions/:integrationId/:balanceId");
diff --git a/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts b/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts
deleted file mode 100644
index 8442df33a..000000000
--- a/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useMatch } from "react-router";
-import { usePositionDetailsHubMatch } from "./use-position-details-hub-match";
-
-export const usePositionDetailsStakeMatch = (path?: string) => {
- const stakeFlowMatch = useMatch(
- `positions/:integrationId/:balanceId/stake/${path ?? "*"}`
- );
- const hubMatch = usePositionDetailsHubMatch();
-
- if (path) return stakeFlowMatch;
-
- return hubMatch;
-};
diff --git a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts
deleted file mode 100644
index d38a709da..000000000
--- a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useMatch } from "react-router";
-import { usePositionDetailsHubMatch } from "./use-position-details-hub-match";
-
-export const useUnstakeOrPendingActionMatch = (path?: string) => {
- const specificMatch = useMatch(
- `positions/:integrationId/:balanceId/${path ?? "*"}`
- );
- const nestedMatch = useMatch("positions/:integrationId/:balanceId/*");
- const hubMatch = usePositionDetailsHubMatch();
-
- if (path) return specificMatch;
-
- return nestedMatch ?? hubMatch;
-};
diff --git a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts
index 2eb7ab88f..e15064510 100644
--- a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts
+++ b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts
@@ -1,25 +1,19 @@
-import { Option, Schema } from "effect";
-import type { YieldPendingActionType } from "../../../domain/action/pending-action";
-import { YieldId } from "../../../domain/identity/identifiers";
-import { usePendingActionSelectValidatorMatch } from "./use-pending-action-select-validator-match";
-import { useUnstakeOrPendingActionMatch } from "./use-unstake-or-pending-action-match";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+import {
+ useWidgetRouteMatch,
+ useWidgetRoutePrefixMatch,
+} from "../../navigation/index";
export const useUnstakeOrPendingActionParams = () => {
- const unstakeOrPendingActionFlowMatch = useUnstakeOrPendingActionMatch();
- const pendingActionSelectValidatorMatch =
- usePendingActionSelectValidatorMatch();
+ const positionMatch = useWidgetRoutePrefixMatch(widgetRoutes.positionDetails);
+ const pendingActionSelectValidatorMatch = useWidgetRouteMatch(
+ widgetRoutes.positionSelectValidator
+ );
- const { balanceId, integrationId: rawIntegrationId } =
- unstakeOrPendingActionFlowMatch?.params ??
- pendingActionSelectValidatorMatch?.params ??
- {};
-
- const integrationId = rawIntegrationId
- ? Schema.decodeOption(YieldId)(rawIntegrationId).pipe(Option.getOrUndefined)
- : undefined;
-
- const pendingActionType = pendingActionSelectValidatorMatch?.params
- .pendingActionType as YieldPendingActionType | undefined;
+ const balanceId = positionMatch?.balanceId;
+ const integrationId = positionMatch?.integrationId;
+ const pendingActionType =
+ pendingActionSelectValidatorMatch?.pendingActionType;
return {
balanceId: balanceId ?? null,
diff --git a/packages/widget/src/features/position-details/state/workflow.ts b/packages/widget/src/features/position-details/state/workflow.ts
index 797f36c2c..41e41dc56 100644
--- a/packages/widget/src/features/position-details/state/workflow.ts
+++ b/packages/widget/src/features/position-details/state/workflow.ts
@@ -4,7 +4,10 @@ import * as Atom from "effect/reactivity/Atom";
import type { PendingActionStateKey } from "../../../domain/action/action-command";
import type { YieldPendingActionType } from "../../../domain/action/pending-action";
import { exactZero } from "../../../domain/finance/exact";
-import type { TokenAddress } from "../../../domain/identity/identifiers";
+import type {
+ TokenAddress,
+ YieldId,
+} from "../../../domain/identity/identifiers";
import type { YieldBalanceType } from "../../../domain/portfolio/positions";
import type { Token } from "../../../domain/token/token";
import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope";
@@ -89,7 +92,7 @@ export class PositionDetailsWorkflowKey extends Data.TaggedClass(
"PositionDetailsWorkflowKey"
)<{
readonly balanceId: string | null;
- readonly integrationId: string | null;
+ readonly integrationId: YieldId | null;
readonly pendingActionType: YieldPendingActionType | null;
readonly scope: WalletScopeKey;
}> {}
diff --git a/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx b/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx
index f828828c4..d7301f6a5 100644
--- a/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx
+++ b/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx
@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
-import { Navigate } from "react-router";
import { isCancellingBalance } from "../../../../domain/earn/confidential";
import { getExtendedYieldType } from "../../../../domain/earn/yield";
import { groupPositionBalanceRows } from "../../../../domain/portfolio/positions";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import {
formatRewardRatePercent,
formatTokenAmount,
@@ -14,6 +14,7 @@ import { Spinner } from "../../../../shared/ui/primitives/spinner";
import { Heading } from "../../../../shared/ui/primitives/typography/heading";
import { Text } from "../../../../shared/ui/primitives/typography/text";
import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation";
+import { WidgetNavigate } from "../../../navigation/views";
import { useTrackPage } from "../../../tracking/index";
import { AnimationPage, PageContainer } from "../../../widget-shell/views";
import { RewardRateBreakdown } from "../../../yield-summary/views";
@@ -59,7 +60,7 @@ const PositionDetails = () => {
const { locale } = useWidgetPresentation();
if (!isLoading && positionLost) {
- return ;
+ return ;
}
const balanceRows = positionBalancesByTypeValue
diff --git a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts
index 7b4794ad5..01f78843d 100644
--- a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts
+++ b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts
@@ -1,8 +1,9 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import type { YieldId } from "../../../../../domain/identity/identifiers";
+import { widgetRoutes } from "../../../../../services/navigation/widget-routes";
+import { useWidgetRouteMatch } from "../../../../navigation/index";
import { useWalletScopeRoute } from "../../../../wallet/index";
import { usePositionDetails } from "../../../react/use-position-details";
-import { usePositionDetailsStakeMatch } from "../../../react/use-position-details-stake-match";
import { PositionDetailsStakeEntryKey } from "../../../state/stake-entry";
import {
hidePositionDetailsStakeTokenBalanceAtom,
@@ -18,10 +19,10 @@ import {
export const usePositionDetailsStake = () => {
const walletScope = useWalletScopeRoute();
- const match = usePositionDetailsStakeMatch();
+ const match = useWidgetRouteMatch(widgetRoutes.positionDetails);
const key = new PositionDetailsStakeEntryKey({
- balanceId: match?.params.balanceId ?? "",
- integrationId: (match?.params.integrationId ?? "") as YieldId,
+ balanceId: match?.balanceId ?? "",
+ integrationId: (match?.integrationId ?? "") as YieldId,
walletScope,
});
const view = useAtomValue(positionDetailsStakeViewAtom(key));
diff --git a/packages/widget/src/features/position-details/ui/dashboard/index.tsx b/packages/widget/src/features/position-details/ui/dashboard/index.tsx
index 1ea5baadf..39290cbd1 100644
--- a/packages/widget/src/features/position-details/ui/dashboard/index.tsx
+++ b/packages/widget/src/features/position-details/ui/dashboard/index.tsx
@@ -1,10 +1,13 @@
import { useTranslation } from "react-i18next";
-import { Navigate, Outlet, useMatch } from "react-router";
+import { Outlet } from "react-router";
+import { widgetRoutes } from "../../../../services/navigation/widget-routes";
import {
PositionDetailsBreadcrumb as PositionDetailsBreadcrumbLayout,
PositionDetailsPane,
} from "../../../../shared/ui/components/position-details";
import { Box } from "../../../../shared/ui/primitives/box";
+import { useWidgetRoutePrefixMatch } from "../../../navigation/index";
+import { WidgetNavigate } from "../../../navigation/views";
import {
AnimationPage,
BackButton,
@@ -36,18 +39,19 @@ const PositionBreadcrumb = ({
const PositionDetailsPageComponent = () => {
const { t } = useTranslation();
const positionDetails = usePositionDetails();
- const flowSegment = useMatch("positions/:integrationId/:balanceId/:flow/*")
- ?.params.flow;
+ const stakeFlow = useWidgetRoutePrefixMatch(widgetRoutes.positionStake);
+ const unstakeFlow = useWidgetRoutePrefixMatch(widgetRoutes.positionUnstake);
+ const pendingActionFlow = useWidgetRoutePrefixMatch(
+ widgetRoutes.positionPendingAction
+ );
const layout = resolvePositionDetailsPageLayout({
...positionDetails,
flowActive:
- flowSegment === "stake" ||
- flowSegment === "unstake" ||
- flowSegment === "pending-action",
+ stakeFlow !== null || unstakeFlow !== null || pendingActionFlow !== null,
});
if (layout === "PositionLost") {
- return ;
+ return ;
}
const shouldShowActions = layout === "Actions";
diff --git a/packages/widget/src/features/wallet/react/wallet-scope-route.tsx b/packages/widget/src/features/wallet/react/wallet-scope-route.tsx
index 0950af04d..51982f7ca 100644
--- a/packages/widget/src/features/wallet/react/wallet-scope-route.tsx
+++ b/packages/widget/src/features/wallet/react/wallet-scope-route.tsx
@@ -1,12 +1,14 @@
import { useAtomValue } from "@effect/atom-react";
import { createContext, useContext, useState } from "react";
-import { Navigate, Outlet } from "react-router";
+import { Outlet } from "react-router";
import {
sameWalletScopeOwner,
type WalletScopeKey,
} from "../../../domain/wallet/wallet-scope";
+import type { WidgetPath } from "../../../services/navigation/widget-routes";
import { walletScopeFromState } from "../../../services/wallet/wallet-scope-adapter";
import type { NormalizedWalletState } from "../../../services/wallet/wallet-state";
+import { WidgetNavigate } from "../../navigation/views";
import { currentWalletStateAtom } from "../state/selectors";
const WalletScopeRouteContext = createContext(null);
@@ -15,7 +17,7 @@ export const WalletScopeRoute = ({
fallbackPath,
walletState,
}: {
- readonly fallbackPath: string;
+ readonly fallbackPath: WidgetPath;
readonly walletState: NormalizedWalletState;
}) => {
const walletScope = walletScopeFromState(walletState);
@@ -31,7 +33,7 @@ export const WalletScopeRoute = ({
(initialWalletScope !== null &&
!sameWalletScopeOwner(initialWalletScope, walletScope))
) {
- return ;
+ return ;
}
return (
@@ -44,7 +46,7 @@ export const WalletScopeRoute = ({
export const WalletScopeRouteGuard = ({
fallbackPath,
}: {
- readonly fallbackPath: string;
+ readonly fallbackPath: WidgetPath;
}) => {
const walletState = useAtomValue(currentWalletStateAtom);
diff --git a/packages/widget/src/features/widget-shell/composition.ts b/packages/widget/src/features/widget-shell/composition.ts
index 4e24feb04..cbdea01fd 100644
--- a/packages/widget/src/features/widget-shell/composition.ts
+++ b/packages/widget/src/features/widget-shell/composition.ts
@@ -1,4 +1,5 @@
export { AnimationLayout } from "./ui/animation-layout";
+export type { PageTransition } from "./ui/animation-page";
export { AppContainer } from "./ui/app-container";
export { CompactLayout } from "./ui/compact/compact-layout";
export { Details } from "./ui/compact/details/details.page.tsx";
diff --git a/packages/widget/src/features/widget-shell/react/use-details-match.ts b/packages/widget/src/features/widget-shell/react/use-details-match.ts
index 9a285e4d6..45707bfff 100644
--- a/packages/widget/src/features/widget-shell/react/use-details-match.ts
+++ b/packages/widget/src/features/widget-shell/react/use-details-match.ts
@@ -1,9 +1,13 @@
-import { useMatch } from "react-router";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+import { useWidgetRouteMatch } from "../../navigation/index";
+/** True on the Compact tab pages: Earn, Positions, and Activity. */
export const useDetailsMatch = () => {
- const rootMatch = useMatch("/");
- const positionsMatch = useMatch("/positions");
- const activityMatch = useMatch("/activity");
+ const earnMatch = useWidgetRouteMatch(widgetRoutes.earn);
+ const positionsMatch = useWidgetRouteMatch(widgetRoutes.positions);
+ const activityMatch = useWidgetRouteMatch(widgetRoutes.activity);
- return rootMatch || positionsMatch || activityMatch;
+ return (
+ earnMatch !== null || positionsMatch !== null || activityMatch !== null
+ );
};
diff --git a/packages/widget/src/features/widget-shell/react/use-header.ts b/packages/widget/src/features/widget-shell/react/use-header.ts
index 66edca564..03cd48d18 100644
--- a/packages/widget/src/features/widget-shell/react/use-header.ts
+++ b/packages/widget/src/features/widget-shell/react/use-header.ts
@@ -1,9 +1,9 @@
import { useAtomSet } from "@effect/atom-react";
import { Option } from "effect";
import * as AsyncResult from "effect/reactivity/AsyncResult";
-import { useNavigate } from "react-router";
import { shouldShowDisconnect } from "../../../services/wallet/wallet-connectors";
import { useLeaveConfirmation } from "../../leave-confirmation/index";
+import { useWidgetNavigate } from "../../navigation/index";
import { useTrackEvent } from "../../tracking/index";
import { useSKWallet, useWalletConfig } from "../../wallet/index";
import { useWidgetConfig } from "../../widget-configuration/index";
@@ -13,7 +13,7 @@ import { useDetailsMatch } from "./use-details-match";
import { useElementAtomRef } from "./use-element-atom-ref";
export const useHeader = () => {
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
const headerRef = useElementAtomRef(headerElementAtom);
@@ -41,7 +41,7 @@ export const useHeader = () => {
if (!showBack) return;
trackEvent("backClicked");
- navigate(-1);
+ navigation.back();
};
const disconnectWidget = useAtomSet(disconnectWidgetAtom);
diff --git a/packages/widget/src/features/widget-shell/ui/animation-page.tsx b/packages/widget/src/features/widget-shell/ui/animation-page.tsx
index b38418a17..0982b93e2 100644
--- a/packages/widget/src/features/widget-shell/ui/animation-page.tsx
+++ b/packages/widget/src/features/widget-shell/ui/animation-page.tsx
@@ -7,31 +7,11 @@ export type PageTransition = Pick<
"animate" | "exit" | "initial"
>;
-export const compactPageTransition = {
- initial: { opacity: 0, scale: 1, translateY: "-15px" },
- animate: {
- opacity: 1,
- translateY: 0,
- transition: { duration: 0.5, delay: 0.1 },
- },
- exit: {
- opacity: 0,
- filter: "blur(8px)",
- scale: 0.8,
- transition: { duration: 0.4 },
- },
-} satisfies PageTransition;
-
-export const dashboardPageTransition = {
- initial: { opacity: 0 },
- animate: { opacity: 1, transition: { duration: 0.4, delay: 0.05 } },
- exit: { opacity: 0, filter: "blur(8px)", transition: { duration: 0.1 } },
-} satisfies PageTransition;
-
-/** The page enter and exit animation; the app container sets it per Layout. */
-export const PageTransitionContext = createContext(
- compactPageTransition
-);
+/**
+ * The page enter and exit animation; the app container provides it. Without a
+ * provider, pages render without a transition.
+ */
+export const PageTransitionContext = createContext({});
export const AnimationPage = ({ children }: PropsWithChildren) => {
const transition = useContext(PageTransitionContext);
diff --git a/packages/widget/src/features/widget-shell/ui/app-container.tsx b/packages/widget/src/features/widget-shell/ui/app-container.tsx
index dd23c8b83..4579dd44c 100644
--- a/packages/widget/src/features/widget-shell/ui/app-container.tsx
+++ b/packages/widget/src/features/widget-shell/ui/app-container.tsx
@@ -1,5 +1,4 @@
import type { PropsWithChildren } from "react";
-import type { WidgetLayout } from "../../../services/config/widget-config-model";
import {
type LayoutAttributeValue,
layoutAttribute,
@@ -7,46 +6,28 @@ import {
import { Box } from "../../../shared/ui/primitives/box";
import { useElementAtomRef } from "../react/use-element-atom-ref";
import { appContainerElementAtom } from "../state/app-container";
-import {
- compactPageTransition,
- dashboardPageTransition,
- type PageTransition,
- PageTransitionContext,
-} from "./animation-page";
+import { type PageTransition, PageTransitionContext } from "./animation-page";
import { appContainer } from "./layout.css";
/**
- * How the shell presents each Layout: the Layout attribute that scopes
- * Layout-specific styles, and the page enter and exit animation.
+ * The shell's root element: scopes Layout-specific styles through the Layout
+ * attribute and provides the page enter and exit animation.
*/
-const layoutPresentation: Readonly<
- Record<
- WidgetLayout,
- {
- readonly attribute: LayoutAttributeValue;
- readonly pageTransition: PageTransition;
- }
- >
-> = {
- Compact: { attribute: "compact", pageTransition: compactPageTransition },
- Dashboard: {
- attribute: "dashboard",
- pageTransition: dashboardPageTransition,
- },
-};
-
export const AppContainer = ({
layout,
+ pageTransition,
children,
-}: PropsWithChildren<{ readonly layout: WidgetLayout }>) => {
+}: PropsWithChildren<{
+ readonly layout: LayoutAttributeValue;
+ readonly pageTransition: PageTransition;
+}>) => {
const appContainerRef = useElementAtomRef(appContainerElementAtom);
- const { attribute, pageTransition } = layoutPresentation[layout];
return (
{children}
diff --git a/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx b/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx
index 0d135cd2a..413207a69 100644
--- a/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx
+++ b/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx
@@ -1,8 +1,15 @@
import { motion } from "motion/react";
-import { useLocation, useNavigate } from "react-router";
+import {
+ type WidgetPath,
+ widgetRoutes,
+} from "../../../../../../services/navigation/widget-routes";
import { Divider } from "../../../../../../shared/ui/components/divider";
import { Box } from "../../../../../../shared/ui/primitives/box";
import { useMountAnimation } from "../../../../../mount-animation/index";
+import {
+ useWidgetNavigate,
+ useWidgetRoutePrefixMatch,
+} from "../../../../../navigation/index";
import { useTrackEvent } from "../../../../../tracking/index";
import { useWidgetConfig } from "../../../../../widget-configuration/index";
import { divider } from "../styles.css";
@@ -10,10 +17,10 @@ import { Tab } from "./tab";
type TabsList = "earn" | "positions" | "activity";
-const TABS_MAP = {
- earn: "/",
- positions: "/positions",
- activity: "/activity",
+const TABS_MAP: Record = {
+ earn: widgetRoutes.earn.build(),
+ positions: widgetRoutes.positions.build(),
+ activity: widgetRoutes.activity.build(),
};
type TabsProps = {
@@ -22,12 +29,13 @@ type TabsProps = {
const Tabs = ({ pendingActionsCount }: TabsProps) => {
const trackEvent = useTrackEvent();
- const navigate = useNavigate();
+ const navigation = useWidgetNavigate();
- const location = useLocation();
+ const positionsMatch = useWidgetRoutePrefixMatch(widgetRoutes.positions);
+ const activityMatch = useWidgetRoutePrefixMatch(widgetRoutes.activity);
const getSelectedTab = (): TabsList => {
- if (location.pathname.startsWith("/positions")) return "positions";
- if (location.pathname.startsWith("/activity")) return "activity";
+ if (positionsMatch) return "positions";
+ if (activityMatch) return "activity";
return "earn";
};
const selectedTab = getSelectedTab();
@@ -37,7 +45,7 @@ const Tabs = ({ pendingActionsCount }: TabsProps) => {
trackEvent("tabClicked", { selected });
- navigate(TABS_MAP[selected]);
+ navigation.push(TABS_MAP[selected]);
};
return (
diff --git a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts
index c2c3c24a3..c78f1df5c 100644
--- a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts
+++ b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts
@@ -27,7 +27,10 @@ import type {
} from "../../../domain/earn/models";
import type { ValidatorKey } from "../../../domain/earn/validator";
import { exactDecimal } from "../../../domain/finance/exact";
-import type { ProviderOption } from "../../../domain/identity/identifiers";
+import type {
+ ProviderOption,
+ YieldId,
+} from "../../../domain/identity/identifiers";
import type { YieldBalanceType } from "../../../domain/portfolio/positions";
import type { Token } from "../../../domain/token/token";
import {
@@ -35,10 +38,11 @@ import {
WalletScopeKey,
} from "../../../domain/wallet/wallet-scope";
import {
- toWidgetPath,
+ type WidgetFlowDestination,
type WidgetPath,
- type WidgetPathInput,
-} from "../../../services/navigation/widget-navigation";
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../services/navigation/widget-routes";
import {
ConfidentialTransactionWorkflowInput,
makeDirectTransactionLedger,
@@ -114,13 +118,13 @@ export type YieldTransactionFlowIntake =
| ManageYieldTransactionFlowIntake
| YieldActionContinuationIntake;
-type YieldTransactionFlowDestination = Readonly<{
- readonly completePath: WidgetPath;
- /** Where finishing the flow returns: the tab its mount belongs to. */
- readonly finishPath: WidgetPath;
- readonly reviewPath: WidgetPath;
- readonly stepsPath: WidgetPath;
-}>;
+type YieldTransactionFlowDestination = WidgetFlowDestination<
+ typeof YieldFlowNavigationState.Type
+> &
+ Readonly<{
+ /** Where finishing the flow returns: the tab its mount belongs to. */
+ readonly finishPath: WidgetPath;
+ }>;
type YieldTransactionFlowMount =
| Readonly<{
@@ -130,17 +134,17 @@ type YieldTransactionFlowMount =
| Readonly<{
readonly _tag: "PositionExit";
readonly balanceId: string;
- readonly integrationId: string;
+ readonly integrationId: YieldId;
}>
| Readonly<{
readonly _tag: "PositionManage";
readonly balanceId: string;
- readonly integrationId: string;
+ readonly integrationId: YieldId;
}>
| Readonly<{
readonly _tag: "PositionStake";
readonly balanceId: string;
- readonly integrationId: string;
+ readonly integrationId: YieldId;
}>;
export type YieldTransactionFlowEnterMount = Extract<
@@ -262,51 +266,28 @@ const YieldFlowNavigationState = Schema.Struct({
executionHandoff: Schema.optional(Schema.instanceOf(YieldFlowHandoff)),
});
-export const makeYieldFlowNavigationState = (
- session: YieldFlowSession,
- executionHandoff?: YieldFlowHandoff
-): typeof YieldFlowNavigationState.Type =>
- executionHandoff === undefined
- ? { yieldFlowSession: session }
- : { yieldFlowSession: session, executionHandoff };
-
-export const decodeYieldFlowNavigationState = (
- state: unknown
-): Option.Option =>
- Schema.decodeUnknownOption(YieldFlowNavigationState)(state).pipe(
- Option.map((decoded) => decoded.yieldFlowSession)
- );
+export type YieldFlowMountTag = YieldTransactionFlowMount["_tag"];
-export const decodeYieldFlowExecutionHandoff = (
- state: unknown
-): YieldFlowHandoff | null =>
- Schema.decodeUnknownOption(YieldFlowNavigationState)(state).pipe(
- Option.flatMap((decoded) =>
- Option.fromUndefinedOr(decoded.executionHandoff)
+/**
+ * Each mount's Review, Steps, and Complete routes, carrying the Session (and
+ * on Steps and Complete, the handoff) in their navigation state.
+ */
+export const yieldFlowRoutes = {
+ Earn: widgetYieldFlowRoutes.Earn.withState(YieldFlowNavigationState),
+ PositionExit: widgetYieldFlowRoutes.PositionExit.withState(
+ YieldFlowNavigationState
+ ),
+ PositionManage: widgetYieldFlowRoutes.PositionManage.withState(
+ YieldFlowNavigationState
+ ),
+ PositionStake: widgetYieldFlowRoutes.PositionStake.withState(
+ YieldFlowNavigationState
+ ),
+ YieldActionContinuation:
+ widgetYieldFlowRoutes.YieldActionContinuation.withState(
+ YieldFlowNavigationState
),
- Option.getOrNull
- );
-
-type YieldTransactionFlowRouteBase = "" | WidgetPathInput;
-
-const positionFlowSegments = {
- PositionExit: "unstake",
- PositionManage: "pending-action",
- PositionStake: "stake",
-} as const;
-
-const makeYieldTransactionFlowDestination = ({
- finishPath,
- routeBase,
-}: {
- readonly finishPath: WidgetPathInput;
- readonly routeBase: YieldTransactionFlowRouteBase;
-}): YieldTransactionFlowDestination => ({
- completePath: toWidgetPath(`${routeBase}/complete`),
- finishPath: toWidgetPath(finishPath),
- reviewPath: toWidgetPath(`${routeBase}/review`),
- stepsPath: toWidgetPath(`${routeBase}/steps`),
-});
+} as const satisfies Record;
const copyYieldTransactionFlowIntake = (
intake: YieldTransactionFlowIntake,
@@ -323,34 +304,42 @@ export const makeYieldFlowSession = (
command: StartYieldTransactionFlow,
walletScope: WalletScopeKey
): YieldFlowSession => {
- const destination = (() => {
+ const destination = ((): YieldTransactionFlowDestination => {
switch (command.mount._tag) {
case "YieldActionContinuation": {
if (command.intake._tag !== "YieldActionContinuation") {
throw new Error("Expected Yield Action Continuation intake.");
}
- const routeBase = `/activity/${encodeURIComponent(
- command.intake.action.id
- )}` as WidgetPathInput;
return {
- completePath: toWidgetPath(`${routeBase}/complete`),
- finishPath: toWidgetPath("/activity"),
- reviewPath: toWidgetPath(routeBase),
- stepsPath: toWidgetPath(`${routeBase}/steps`),
+ ...yieldFlowRoutes.YieldActionContinuation.at({
+ actionId: command.intake.action.id,
+ }),
+ finishPath: widgetRoutes.activity.build(),
};
}
case "Earn":
- return makeYieldTransactionFlowDestination({
- finishPath: "/",
- routeBase: "",
- });
+ return {
+ ...yieldFlowRoutes.Earn.at(),
+ finishPath: widgetRoutes.earn.build(),
+ };
case "PositionExit":
case "PositionManage":
+ return {
+ ...yieldFlowRoutes[command.mount._tag].at({
+ balanceId: command.mount.balanceId,
+ integrationId: command.mount.integrationId,
+ }),
+ finishPath: widgetRoutes.positions.build(),
+ };
+ // Only the Dashboard Layout mounts a position's stake flow.
case "PositionStake":
- return makeYieldTransactionFlowDestination({
- finishPath: "/positions",
- routeBase: `/positions/${command.mount.integrationId}/${command.mount.balanceId}/${positionFlowSegments[command.mount._tag]}`,
- });
+ return {
+ ...yieldFlowRoutes.PositionStake.at("Dashboard", {
+ balanceId: command.mount.balanceId,
+ integrationId: command.mount.integrationId,
+ }),
+ finishPath: widgetRoutes.positions.build(),
+ };
}
})();
@@ -370,30 +359,19 @@ export const makeYieldActionContinuationSession = (
intake.walletScope
);
-const removeOptionalTrailingSlash = (pathname: string): string =>
- pathname.length > 1 && pathname.endsWith("/")
- ? pathname.slice(0, -1)
- : pathname;
-
-const yieldFlowRouteGroups = [
- /^()\/(?:review|steps|complete)$/,
- /^(\/positions\/[^/]+\/[^/]+\/(?:stake|unstake|pending-action))\/(?:review|steps|complete)$/,
- /^(\/activity\/[^/]+)(?:\/steps|\/complete)?$/,
-];
-
/**
* The flow mount a pathname belongs to: Review, Steps, and Complete of one
* mount share it, so a layout can keep that subtree (and its Session) mounted
* across the flow's own navigation. Null outside Yield flow routes.
*/
-export const getYieldFlowRouteGroup = (pathname: string): string | null => {
- const normalized = removeOptionalTrailingSlash(pathname);
- for (const group of yieldFlowRouteGroups) {
- const match = group.exec(normalized);
- if (match) return match[1] || "/";
- }
- return null;
-};
+export const getYieldFlowRouteGroup = (pathname: string): string | null =>
+ Option.getOrNull(
+ Option.firstSomeOf(
+ Object.values(widgetYieldFlowRoutes).map((routes) =>
+ routes.mountKey(pathname)
+ )
+ )
+ );
type YieldTransactionFlowReviewPricingInput = {
readonly token: Token;
diff --git a/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx b/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx
index 812f4d131..39837ad33 100644
--- a/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx
+++ b/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx
@@ -1,5 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
-import { Option } from "effect";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import {
createContext,
@@ -7,15 +6,18 @@ import {
useContext,
useState,
} from "react";
-import { Navigate, Outlet, useLocation } from "react-router";
+import { Outlet, useLocation } from "react-router";
+import { widgetRoutes } from "../../../services/navigation/widget-routes";
+import { useWidgetRouteState } from "../../navigation/index";
+import { WidgetNavigate } from "../../navigation/views";
import { walletScopeAtom } from "../../wallet/index";
import {
- decodeYieldFlowExecutionHandoff,
- decodeYieldFlowNavigationState,
getYieldTransactionFlowIntakeVariant,
isYieldTransactionFlowWalletScopeValid,
+ type YieldFlowMountTag,
type YieldFlowSession,
type YieldTransactionFlowIntake,
+ yieldFlowRoutes,
} from "../model/yield-transaction-flow";
import { isYieldTransactionWorkflowFinished } from "../model/yield-transaction-workflow";
import {
@@ -46,9 +48,18 @@ export const useYieldFlowReview = () =>
)
);
+/** The handoff Steps and Complete were navigated to run, if any. */
+const useYieldFlowExecutionHandoff = () => {
+ const session = useYieldFlowSession();
+ return (
+ useWidgetRouteState(yieldFlowRoutes[session.mount._tag])
+ ?.executionHandoff ?? null
+ );
+};
+
/** The Execution of the handoff Steps and Complete were navigated to run. */
export const useYieldFlowExecution = () => {
- const handoff = decodeYieldFlowExecutionHandoff(useLocation().state);
+ const handoff = useYieldFlowExecutionHandoff();
const session = useYieldFlowSession();
if (!handoff) throw new Error("Yield Flow Execution is unavailable.");
return useAtomValue(
@@ -81,16 +92,17 @@ type MountedYieldFlowSession = Readonly<{
*/
export const YieldFlowRoute = ({
expected,
+ mount,
}: {
readonly expected: Exclude<
YieldTransactionFlowIntake["_tag"],
"YieldActionContinuation"
>;
+ readonly mount: Exclude;
}) => {
const location = useLocation();
- const navigated = Option.getOrNull(
- decodeYieldFlowNavigationState(location.state)
- );
+ const navigated =
+ useWidgetRouteState(yieldFlowRoutes[mount])?.yieldFlowSession ?? null;
const [mounted, setMounted] = useState(null);
if (navigated && navigated !== mounted?.session) {
setMounted({ key: location.key, session: navigated });
@@ -100,7 +112,7 @@ export const YieldFlowRoute = ({
!mounted ||
!getYieldTransactionFlowIntakeVariant(mounted.session.intake, expected)
) {
- return ;
+ return ;
}
return ;
};
@@ -113,7 +125,7 @@ export const YieldFlowSessionRoute = ({
}) => {
const walletScope = useAtomValue(walletScopeAtom);
if (!isYieldTransactionFlowWalletScopeValid(session.intake, walletScope)) {
- return ;
+ return ;
}
return (
@@ -127,8 +139,10 @@ export const YieldFlowSessionRoute = ({
* workflow; without an action handed off by Review, it leaves the flow.
*/
export const YieldFlowExecutionRoute = ({ children }: PropsWithChildren) => {
- const handoff = decodeYieldFlowExecutionHandoff(useLocation().state);
- if (!handoff) return ;
+ const handoff = useYieldFlowExecutionHandoff();
+ if (!handoff) {
+ return ;
+ }
return {children};
};
@@ -139,7 +153,7 @@ const ExecutionStarted = ({ children }: PropsWithChildren) => {
const execution = useAtomValue(executionAtom);
const state = useAtomValue(stateAtom);
if (AsyncResult.isFailure(execution) || AsyncResult.isFailure(state)) {
- return ;
+ return ;
}
if (AsyncResult.isInitial(execution)) return null;
return children ?? ;
@@ -150,21 +164,28 @@ export const YieldFlowExecutionPage = ({
children,
page,
}: PropsWithChildren<{ readonly page: "Complete" | "Steps" }>) => {
- const { destination } = useYieldFlowSession();
- const location = useLocation();
+ const session = useYieldFlowSession();
+ const handoff = useYieldFlowExecutionHandoff();
const state = useAtomValue(useYieldFlowExecution().stateAtom);
// Confidential steps run before the workflow has a state.
const finished =
AsyncResult.isSuccess(state) &&
isYieldTransactionWorkflowFinished(state.value);
+ const navigationState = {
+ ...(handoff ? { executionHandoff: handoff } : {}),
+ yieldFlowSession: session,
+ };
if (page === "Steps" && finished) {
return (
-
+
);
}
if (page === "Complete" && !finished) {
return (
-
+
);
}
return children ?? ;
diff --git a/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts b/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts
index 6dcdd5663..7bd6e9100 100644
--- a/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts
+++ b/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts
@@ -69,8 +69,8 @@ export const leaveYieldFlowExecution = (
to: "Exit" | "Review"
) =>
to === "Review"
- ? navigate({ _tag: "Replace", path: destination.reviewPath })
- : navigate({ _tag: "Push", path: destination.finishPath });
+ ? navigate({ _tag: "Replace", to: destination.reviewPath })
+ : navigate({ _tag: "Push", to: destination.finishPath });
/** One handoff's Execution in a Session. */
export class YieldFlowExecutionKey extends Data.Class<{
diff --git a/packages/widget/src/features/yield-transaction-flow/state/review-page.ts b/packages/widget/src/features/yield-transaction-flow/state/review-page.ts
index 624037dc0..a3cedefe0 100644
--- a/packages/widget/src/features/yield-transaction-flow/state/review-page.ts
+++ b/packages/widget/src/features/yield-transaction-flow/state/review-page.ts
@@ -46,7 +46,6 @@ import {
getYieldTransactionFlowGasWarningInput,
getYieldTransactionFlowKycYield,
getYieldTransactionFlowReviewPricingInput,
- makeYieldFlowNavigationState,
type YieldFlowExecutionSource,
YieldFlowHandoff,
type YieldFlowSession,
@@ -192,8 +191,10 @@ export const handOffYieldFlowSource = Effect.fn("handOffYieldFlowSource")(
navigation
.execute({
_tag: "Push",
- path: destination.stepsPath,
- state: makeYieldFlowNavigationState(session, next),
+ to: destination.steps({
+ executionHandoff: next,
+ yieldFlowSession: session,
+ }),
})
.pipe(
Effect.tapError(() =>
diff --git a/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts b/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts
index 5afc77052..dbe504ea7 100644
--- a/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts
+++ b/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts
@@ -8,7 +8,6 @@ import { walletScopeFromState } from "../../../services/wallet/wallet-scope-adap
import { WalletService } from "../../../services/wallet/wallet-service";
import {
isYieldTransactionFlowWalletScopeValid,
- makeYieldFlowNavigationState,
makeYieldFlowSession,
type NavigatingYieldTransactionFlowStart,
type YieldFlowSession,
@@ -53,8 +52,7 @@ export const makeStartYieldTransactionFlow = Effect.gen(function* () {
yield* Effect.uninterruptible(
navigation.execute({
_tag: "Push",
- path: session.destination.reviewPath,
- state: makeYieldFlowNavigationState(session),
+ to: session.destination.review({ yieldFlowSession: session }),
})
);
return { _tag: "Started", session } as const;
diff --git a/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx b/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx
index eec94e0aa..2ad123e9a 100644
--- a/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx
+++ b/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx
@@ -1,6 +1,8 @@
import { Match } from "effect";
import type { ComponentType, ReactNode } from "react";
import { Route } from "react-router";
+import { widgetYieldFlowRoutes } from "../../../services/navigation/widget-routes";
+import type { YieldFlowMountTag } from "../model/yield-transaction-flow";
import {
YieldFlowExecutionPage,
YieldFlowExecutionRoute,
@@ -17,7 +19,7 @@ import { StakeStepsPage } from "./steps/pages/stake-steps.page.tsx";
import { UnstakeStepsPage } from "./steps/pages/unstake-steps.page.tsx";
type YieldFlowRouteMount = {
- readonly journey: "Enter" | "Exit" | "Manage";
+ readonly mount: Exclude;
/**
* Layout rendered inside the Session (it may read the Session intake) around
* Review, Steps, and Complete; it renders their ``.
@@ -32,15 +34,17 @@ type StandardYieldFlowPages = Readonly<{
}>;
const createStandardYieldFlowRoutes = (
- { journey, layout }: YieldFlowRouteMount,
+ { layout, mount }: YieldFlowRouteMount,
+ journey: "Enter" | "Exit" | "Manage",
{ CompletePage, ReviewPage, StepsPage }: StandardYieldFlowPages
) => {
+ const routes = widgetYieldFlowRoutes[mount];
const pages = (
<>
- } />
+ } />
}>
@@ -48,7 +52,7 @@ const createStandardYieldFlowRoutes = (
}
/>
@@ -60,30 +64,31 @@ const createStandardYieldFlowRoutes = (
);
return (
- }>
+ }>
{layout ? {pages} : pages}
);
};
-export const createYieldFlowRoutes = (mount: YieldFlowRouteMount) =>
- Match.value(mount.journey).pipe(
- Match.when("Enter", () =>
- createStandardYieldFlowRoutes(mount, {
+/** Review, Steps, and Complete of the Yield Transaction Flow `mount` starts. */
+export const createYieldFlowRoutes = (route: YieldFlowRouteMount) =>
+ Match.value(route.mount).pipe(
+ Match.whenOr("Earn", "PositionStake", () =>
+ createStandardYieldFlowRoutes(route, "Enter", {
CompletePage: StakeCompletePage,
ReviewPage: StakeReviewPage,
StepsPage: StakeStepsPage,
})
),
- Match.when("Exit", () =>
- createStandardYieldFlowRoutes(mount, {
+ Match.when("PositionExit", () =>
+ createStandardYieldFlowRoutes(route, "Exit", {
CompletePage: UnstakeCompletePage,
ReviewPage: UnstakeReviewPage,
StepsPage: UnstakeStepsPage,
})
),
- Match.when("Manage", () =>
- createStandardYieldFlowRoutes(mount, {
+ Match.when("PositionManage", () =>
+ createStandardYieldFlowRoutes(route, "Manage", {
CompletePage: PendingCompletePage,
ReviewPage: PendingReviewPage,
StepsPage: PendingStepsPage,
diff --git a/packages/widget/src/services/navigation/application-router.ts b/packages/widget/src/services/navigation/application-router.ts
index 2eb59b3c1..a96605576 100644
--- a/packages/widget/src/services/navigation/application-router.ts
+++ b/packages/widget/src/services/navigation/application-router.ts
@@ -2,16 +2,22 @@ import { Context, Effect, Layer, Queue, Stream } from "effect";
import {
createMemoryRouter,
type DataRouter,
+ type Location,
type RouteObject,
} from "react-router";
-const pathnameStream = (router: DataRouter): Stream.Stream =>
- Stream.callback(
+/** The router's current location; match its pathname with registry routes. */
+export type ApplicationLocation = Readonly>;
+
+const locationStream = (
+ router: DataRouter
+): Stream.Stream =>
+ Stream.callback(
(queue) =>
Effect.acquireRelease(
Effect.sync(() => {
const publish = (state: DataRouter["state"]) => {
- Queue.offerUnsafe(queue, state.location.pathname);
+ Queue.offerUnsafe(queue, { pathname: state.location.pathname });
};
const unsubscribe = router.subscribe(publish);
publish(router.state);
@@ -28,7 +34,7 @@ const pathnameStream = (router: DataRouter): Stream.Stream =>
export class ApplicationRouter extends Context.Service<
ApplicationRouter,
{
- readonly pathnames: Stream.Stream;
+ readonly locations: Stream.Stream;
readonly router: DataRouter;
}
>()("@stakekit/widget/services/navigation/ApplicationRouter") {
@@ -43,10 +49,10 @@ export class ApplicationRouter extends Context.Service<
Effect.sync(() => createMemoryRouter([...routes], options)),
(router) => Effect.sync(() => router.dispose())
);
- const pathnames = yield* pathnameStream(router).pipe(
+ const locations = yield* locationStream(router).pipe(
Stream.share({ capacity: 1, replay: 1, strategy: "sliding" })
);
- return ApplicationRouter.of({ pathnames, router });
+ return ApplicationRouter.of({ locations, router });
})
);
}
diff --git a/packages/widget/src/services/navigation/widget-navigation.ts b/packages/widget/src/services/navigation/widget-navigation.ts
index 6c6cde017..08d0e641c 100644
--- a/packages/widget/src/services/navigation/widget-navigation.ts
+++ b/packages/widget/src/services/navigation/widget-navigation.ts
@@ -1,16 +1,13 @@
-import { Context, Data, Effect, Layer, Schema } from "effect";
+import { Context, Data, Effect, Layer } from "effect";
import { ApplicationRouter } from "./application-router";
-
-export const WidgetPath = Schema.TemplateLiteral([
- Schema.Literal("/"),
- Schema.String,
-]).pipe(Schema.brand("WidgetPath"));
-export type WidgetPath = typeof WidgetPath.Type;
-export type WidgetPathInput = typeof WidgetPath.Encoded;
+import {
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "./widget-routes";
export type WidgetNavigationOptions = Readonly<{
readonly scroll?: "preserve" | "reset";
- readonly state?: unknown;
}>;
export type WidgetNavigationCommand =
@@ -22,13 +19,13 @@ export type WidgetNavigationCommand =
| Readonly<
{
readonly _tag: "Push";
- readonly path: WidgetPath;
+ readonly to: WidgetNavigationTarget;
} & WidgetNavigationOptions
>
| Readonly<
{
readonly _tag: "Replace";
- readonly path: WidgetPath;
+ readonly to: WidgetNavigationTarget;
} & WidgetNavigationOptions
>;
@@ -38,8 +35,6 @@ export class WidgetNavigationError extends Data.TaggedError(
readonly cause: unknown;
}> {}
-export const toWidgetPath = Schema.decodeSync(WidgetPath);
-
const resetScroll = async (
options: WidgetNavigationOptions,
canResetScroll: () => boolean
@@ -65,11 +60,11 @@ export type WidgetNavigationService = Readonly<{
command: WidgetNavigationCommand
) => Effect.Effect;
readonly push: (
- path: WidgetPath,
+ to: WidgetNavigationTarget,
options?: WidgetNavigationOptions
) => Effect.Effect;
readonly replace: (
- path: WidgetPath,
+ to: WidgetNavigationTarget,
options?: WidgetNavigationOptions
) => Effect.Effect;
}>;
@@ -83,9 +78,9 @@ export const makeWidgetNavigation = (
case "Back":
return methods.back(command);
case "Push":
- return methods.push(command.path, command);
+ return methods.push(command.to, command);
case "Replace":
- return methods.replace(command.path, command);
+ return methods.replace(command.to, command);
}
},
});
@@ -103,23 +98,22 @@ export class WidgetNavigation extends Context.Service<
await resetScroll(options, canResetScroll);
await router.navigate(-1);
});
- const push: WidgetNavigation["Service"]["push"] = (
- path,
- options = {}
- ) =>
+ const push: WidgetNavigation["Service"]["push"] = (to, options = {}) =>
runNavigation(async () => {
await resetScroll(options, canResetScroll);
- await router.navigate(path, { state: options.state });
+ await router.navigate(widgetTargetPath(to), {
+ state: widgetTargetState(to),
+ });
});
const replace: WidgetNavigation["Service"]["replace"] = (
- path,
+ to,
options = {}
) =>
runNavigation(async () => {
await resetScroll(options, canResetScroll);
- await router.navigate(path, {
+ await router.navigate(widgetTargetPath(to), {
replace: true,
- state: options.state,
+ state: widgetTargetState(to),
});
});
return makeWidgetNavigation({ back, push, replace });
diff --git a/packages/widget/src/services/navigation/widget-routes.ts b/packages/widget/src/services/navigation/widget-routes.ts
new file mode 100644
index 000000000..f1a641ad4
--- /dev/null
+++ b/packages/widget/src/services/navigation/widget-routes.ts
@@ -0,0 +1,408 @@
+import { Option, Schema } from "effect";
+import { YieldPendingActionType } from "../../domain/action/pending-action";
+import { ActionId as BorrowActionId, MarketId } from "../../domain/borrow/ids";
+import { ActionId, YieldId } from "../../domain/identity/identifiers";
+import type { WidgetLayout } from "../config/widget-config-model";
+
+/**
+ * The Widget's route registry: one definition per route. Every absolute path
+ * the Widget renders, navigates to, or matches comes from here; route trees,
+ * navigation, and pathname parsing elsewhere use these definitions.
+ */
+
+declare const widgetPathBrand: unique symbol;
+
+/** An absolute Widget path, built only by a registry route. */
+export type WidgetPath = string & { readonly [widgetPathBrand]: true };
+
+declare const widgetLocationBrand: unique symbol;
+
+/** A path together with the navigation state its route carries. */
+type WidgetLocation = Readonly<{
+ readonly path: WidgetPath;
+ readonly state: State;
+ readonly [widgetLocationBrand]: true;
+}>;
+
+/** Where a navigation goes: a path, or a path with its route's state. */
+export type WidgetNavigationTarget = WidgetPath | WidgetLocation;
+
+export const widgetTargetPath = (target: WidgetNavigationTarget): WidgetPath =>
+ typeof target === "string" ? target : target.path;
+
+export const widgetTargetState = (target: WidgetNavigationTarget): unknown =>
+ typeof target === "string" ? undefined : target.state;
+
+const allLayouts = [
+ "Compact",
+ "Dashboard",
+] as const satisfies ReadonlyArray;
+/** Layouts wide enough for Borrow and position staking. */
+const wideLayouts = [
+ "Dashboard",
+] as const satisfies ReadonlyArray;
+
+type ParamSchema = Schema.Codec;
+
+type ParamNames =
+ Pattern extends `${string}:${infer Name}/${infer Rest}`
+ ? Name | ParamNames
+ : Pattern extends `${string}:${infer Name}`
+ ? Name
+ : never;
+
+type ParamSchemas = {
+ readonly [Name in ParamNames]?: ParamSchema;
+};
+
+type RouteParams = {
+ readonly [Name in ParamNames]: Name extends keyof Schemas
+ ? Schemas[Name] extends { readonly Type: infer Value }
+ ? Value
+ : string
+ : string;
+};
+
+/**
+ * A route mounted in only some Layouts takes the Layout it is built for, so
+ * navigating to it states which Layout it expects.
+ */
+type LayoutArgs = [WidgetLayout] extends [Layout]
+ ? []
+ : [layout: Layout];
+
+type ParamArgs = [keyof Params] extends [never] ? [] : [params: Params];
+
+type WidgetRouteBuildArgs = [
+ ...LayoutArgs,
+ ...ParamArgs,
+];
+
+type Segment =
+ | Readonly<{ readonly _tag: "Param"; readonly name: string }>
+ | Readonly<{ readonly _tag: "Static"; readonly value: string }>;
+
+const parseSegments = (pattern: string): ReadonlyArray =>
+ pattern
+ .split("/")
+ .filter((segment) => segment !== "")
+ .map((segment) =>
+ segment.startsWith(":")
+ ? { _tag: "Param", name: segment.slice(1) }
+ : { _tag: "Static", value: segment.toLowerCase() }
+ );
+
+const pathSegments = (pathname: string): ReadonlyArray =>
+ pathname.split("/").filter((segment) => segment !== "");
+
+const decodeSegment = (segment: string): Option.Option => {
+ try {
+ return Option.some(decodeURIComponent(segment));
+ } catch {
+ return Option.none();
+ }
+};
+
+const findParams = (
+ args: ReadonlyArray
+): Readonly> => {
+ const params = args.find((arg) => typeof arg === "object" && arg !== null);
+ return (params ?? {}) as Readonly>;
+};
+
+/** True when `pathname` is exactly `path`, ignoring a trailing slash. */
+export const isWidgetPath = (pathname: string, path: WidgetPath): boolean =>
+ pathSegments(pathname).join("/") === pathSegments(path).join("/");
+
+class WidgetRoute<
+ const Pattern extends string,
+ Params,
+ Layout extends WidgetLayout,
+> {
+ /** Every path inside this route, for catch-all redirects. */
+ readonly descendants: `${Pattern}/*`;
+ readonly paramNames: ReadonlyArray;
+ readonly #segments: ReadonlyArray;
+
+ constructor(
+ /** The absolute route pattern route trees mount. */
+ readonly pattern: Pattern,
+ /** The Layouts whose route trees mount this route. */
+ readonly layouts: ReadonlyArray,
+ /** Decoders of the params with a domain schema. */
+ readonly paramSchemas: Readonly>
+ ) {
+ this.descendants = `${pattern}/*`;
+ this.#segments = parseSegments(pattern);
+ this.paramNames = this.#segments.flatMap((segment) =>
+ segment._tag === "Param" ? [segment.name] : []
+ );
+ }
+
+ build(...args: WidgetRouteBuildArgs): WidgetPath {
+ const params = findParams(args);
+ const segments = this.#segments.map((segment) =>
+ segment._tag === "Param"
+ ? encodeURIComponent(params[segment.name] ?? "")
+ : segment.value
+ );
+ return `/${segments.join("/")}` as WidgetPath;
+ }
+
+ /** Binds the navigation state this route's owner carries with its path. */
+ withState(schema: Schema.Codec) {
+ return {
+ build: (
+ state: State,
+ ...args: WidgetRouteBuildArgs
+ ): WidgetLocation =>
+ ({ path: this.build(...args), state }) as WidgetLocation,
+ state: Schema.decodeUnknownOption(schema),
+ };
+ }
+
+ /** This route's params when `pathname` is exactly this route. */
+ match(pathname: string): Option.Option {
+ return this.#match(pathSegments(pathname), true);
+ }
+
+ /** This route's params when `pathname` is this route or inside it. */
+ matchPrefix(pathname: string): Option.Option {
+ return this.#match(pathSegments(pathname), false);
+ }
+
+ #match(
+ segments: ReadonlyArray,
+ exact: boolean
+ ): Option.Option {
+ if (
+ segments.length < this.#segments.length ||
+ (exact && segments.length !== this.#segments.length)
+ ) {
+ return Option.none();
+ }
+ const params: Record = {};
+ for (const [index, segment] of this.#segments.entries()) {
+ const value = segments[index] ?? "";
+ if (segment._tag === "Static") {
+ if (value.toLowerCase() !== segment.value) return Option.none();
+ continue;
+ }
+ const decoded = decodeSegment(value).pipe(
+ Option.flatMap((raw) => {
+ const schema = this.paramSchemas[segment.name];
+ return schema ? Schema.decodeOption(schema)(raw) : Option.some(raw);
+ })
+ );
+ if (Option.isNone(decoded)) return Option.none();
+ params[segment.name] = decoded.value;
+ }
+ return Option.some(params as Params);
+ }
+}
+
+/** One mount's Review, Steps, and Complete paths. */
+type WidgetFlowPaths = Readonly<{
+ readonly completePath: WidgetPath;
+ readonly reviewPath: WidgetPath;
+ readonly stepsPath: WidgetPath;
+}>;
+
+/** One mount's flow paths, and their locations carrying the flow state. */
+export type WidgetFlowDestination = WidgetFlowPaths &
+ Readonly<{
+ readonly complete: (state: State) => WidgetLocation;
+ readonly review: (state: State) => WidgetLocation;
+ readonly steps: (state: State) => WidgetLocation;
+ }>;
+
+/** The route a flow mounts under, in whichever Layouts mount it. */
+type WidgetFlowBase = Readonly<{
+ readonly build: (...args: never) => WidgetPath;
+ readonly match: (pathname: string) => Option.Option;
+ readonly paramSchemas: Readonly>;
+ readonly pattern: string;
+}>;
+
+class WidgetFlowRoutes {
+ constructor(
+ /** The route the flow mounts under. */
+ readonly base: WidgetFlowBase,
+ readonly review: WidgetRoute,
+ readonly steps: WidgetRoute,
+ readonly complete: WidgetRoute
+ ) {}
+
+ at(...args: WidgetRouteBuildArgs): WidgetFlowPaths {
+ return {
+ completePath: this.complete.build(...args),
+ reviewPath: this.review.build(...args),
+ stepsPath: this.steps.build(...args),
+ };
+ }
+
+ /**
+ * The base path of the mount a pathname shows, when it is one of this
+ * group's pages: its Review, Steps, and Complete share it.
+ */
+ mountKey(pathname: string): Option.Option {
+ // The key names a mount, not a navigation target, so it needs no Layout.
+ const buildBase = this.base.build.bind(this.base) as unknown as (
+ params: Params
+ ) => WidgetPath;
+ return Option.firstSomeOf([
+ this.review.match(pathname),
+ this.steps.match(pathname),
+ this.complete.match(pathname),
+ ]).pipe(Option.map(buildBase));
+ }
+
+ /** Binds the navigation state the flow's pages carry. */
+ withState(
+ schema: Schema.Codec
+ ): StatefulWidgetFlowRoutes {
+ return new StatefulWidgetFlowRoutes(
+ this,
+ Schema.decodeUnknownOption(schema)
+ );
+ }
+}
+
+/** A flow group with the navigation state its owner bound to it. */
+class StatefulWidgetFlowRoutes {
+ constructor(
+ readonly routes: WidgetFlowRoutes,
+ /** Decodes a location's state as the flow's state. */
+ readonly state: (locationState: unknown) => Option.Option
+ ) {}
+
+ at(
+ ...args: WidgetRouteBuildArgs
+ ): WidgetFlowDestination {
+ const paths = this.routes.at(...args);
+ const located =
+ (path: WidgetPath) =>
+ (state: State): WidgetLocation =>
+ ({ path, state }) as WidgetLocation;
+ return {
+ ...paths,
+ complete: located(paths.completePath),
+ review: located(paths.reviewPath),
+ steps: located(paths.stepsPath),
+ };
+ }
+}
+
+const route = <
+ const Pattern extends string,
+ Layout extends WidgetLayout,
+ const Schemas extends ParamSchemas = Record,
+>(
+ pattern: Pattern,
+ layouts: ReadonlyArray,
+ schemas?: Schemas
+) =>
+ new WidgetRoute, Layout>(
+ pattern,
+ layouts,
+ (schemas ?? {}) as Readonly>
+ );
+
+const flowRoutes = (
+ base: WidgetFlowBase,
+ layouts: ReadonlyArray
+) => {
+ const prefix = base.pattern === "/" ? "" : base.pattern;
+ const page = (name: "complete" | "review" | "steps") =>
+ new WidgetRoute(
+ `${prefix}/${name}`,
+ layouts,
+ base.paramSchemas
+ );
+ return new WidgetFlowRoutes(
+ base,
+ page("review"),
+ page("steps"),
+ page("complete")
+ );
+};
+
+const positionParams = { integrationId: YieldId } as const;
+const activityActionParams = { actionId: ActionId } as const;
+const borrowPositionParams = { marketId: MarketId } as const;
+
+const activityAction = route(
+ "/activity/:actionId",
+ allLayouts,
+ activityActionParams
+);
+
+export const widgetRoutes = {
+ earn: route("/", allLayouts),
+ positions: route("/positions", allLayouts),
+ positionDetails: route(
+ "/positions/:integrationId/:balanceId",
+ allLayouts,
+ positionParams
+ ),
+ positionSelectValidator: route(
+ "/positions/:integrationId/:balanceId/select-validator/:pendingActionType",
+ allLayouts,
+ { ...positionParams, pendingActionType: YieldPendingActionType }
+ ),
+ /** Base of the stake flow; Compact redirects its paths to the hub. */
+ positionStake: route(
+ "/positions/:integrationId/:balanceId/stake",
+ allLayouts,
+ positionParams
+ ),
+ positionUnstake: route(
+ "/positions/:integrationId/:balanceId/unstake",
+ allLayouts,
+ positionParams
+ ),
+ positionPendingAction: route(
+ "/positions/:integrationId/:balanceId/pending-action",
+ allLayouts,
+ positionParams
+ ),
+ activity: route("/activity", allLayouts),
+ activityAction,
+ activityBorrowAction: route("/activity/borrow/:actionId", allLayouts, {
+ actionId: BorrowActionId,
+ }),
+ borrow: route("/borrow", wideLayouts),
+ /** Base of market positions; its unknown paths redirect to Positions. */
+ positionsBorrow: route("/positions/borrow", wideLayouts),
+ borrowPosition: route(
+ "/positions/borrow/:marketId",
+ wideLayouts,
+ borrowPositionParams
+ ),
+ borrowPositionAction: route(
+ "/positions/borrow/:marketId/action/:actionId",
+ wideLayouts,
+ borrowPositionParams
+ ),
+} as const;
+
+/** Yield Transaction Flow pages, by the mount that starts them. */
+export const widgetYieldFlowRoutes = {
+ Earn: flowRoutes(widgetRoutes.earn, allLayouts),
+ PositionStake: flowRoutes(widgetRoutes.positionStake, wideLayouts),
+ PositionExit: flowRoutes(widgetRoutes.positionUnstake, allLayouts),
+ PositionManage: flowRoutes(widgetRoutes.positionPendingAction, allLayouts),
+ /** A continuation's Review is the action's own details route. */
+ YieldActionContinuation: new WidgetFlowRoutes(
+ activityAction,
+ activityAction,
+ route("/activity/:actionId/steps", allLayouts, activityActionParams),
+ route("/activity/:actionId/complete", allLayouts, activityActionParams)
+ ),
+} as const;
+
+/** Borrow Transaction Flow pages, by the entry that starts them. */
+export const widgetBorrowFlowRoutes = {
+ BorrowEntry: flowRoutes(widgetRoutes.borrow, wideLayouts),
+ MarketPosition: flowRoutes(widgetRoutes.borrowPosition, wideLayouts),
+} as const;
diff --git a/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx b/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx
index ac543e355..b48550cfe 100644
--- a/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx
+++ b/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx
@@ -1,10 +1,21 @@
+import { MotionGlobalConfig } from "motion/react";
import { avalanche } from "viem/chains";
+import { afterEach } from "vitest";
import { userEvent } from "vitest/browser";
+import { YieldCategory } from "../../../../src/public-api/types";
import { formatAddress } from "../../../../src/shared/lib/general";
import { describe, expect, it, vi } from "../../../utils/test-extend";
import { renderApp } from "../../../utils/test-utils";
+import { waitForMs } from "../../../utils/wait";
import { setup } from "./setup";
+/** Outlasts the page exit transition that used to drop the dialog. */
+const pastPageExitMs = 1_000;
+
+afterEach(() => {
+ MotionGlobalConfig.skipAnimations = true;
+});
+
describe("Staking flow Leave Confirmation", () => {
it("asks before Cancel abandons a stake the wallet is signing", async ({
worker,
@@ -74,4 +85,98 @@ describe("Staking flow Leave Confirmation", () => {
await app.unmount();
});
+
+ it("asks before a dashboard category tab abandons a stake the wallet is signing", async ({
+ worker,
+ }) => {
+ MotionGlobalConfig.skipAnimations = false;
+ const { customConnectors, account, requestFn } = await setup(worker, {
+ includeDefiYield: true,
+ });
+ // The wallet never answers the signature request: Steps stays Signing.
+ requestFn.mockImplementation(async ({ method }: { method: string }) => {
+ switch (method) {
+ case "eth_sendTransaction":
+ return Promise.withResolvers().promise;
+ case "eth_chainId":
+ return avalanche.id;
+ case "eth_requestAccounts":
+ return [account];
+ default:
+ throw new Error("unhandled method");
+ }
+ });
+ const app = await renderApp({
+ skProps: {
+ apiKey: import.meta.env.VITE_API_KEY,
+ dashboardVariant: true,
+ yieldCategoryOrder: [YieldCategory.Stake, YieldCategory.DeFi],
+ },
+ walletListFactory: customConnectors,
+ });
+ const tab = (label: string) => {
+ const section = app.container.querySelector("[data-rk='tabs-section']");
+ const found = [...(section?.querySelectorAll("p") ?? [])].find(
+ (el) => el.textContent === label
+ );
+ if (!found) throw new Error(`Missing dashboard tab: ${label}`);
+ return found;
+ };
+ const tabState = (label: string) => tab(label).getAttribute("data-state");
+ const signatureRequests = () =>
+ requestFn.mock.calls.filter(
+ ([{ method }]) => method === "eth_sendTransaction"
+ ).length;
+ const dialog = app.getByTestId("leave-confirmation");
+ const steps = app.getByText("Follow Steps");
+
+ await expect
+ .element(app.getByText(formatAddress(account)))
+ .toBeInTheDocument();
+ await expect.poll(() => tabState("DeFi")).toBe("default");
+ await expect.poll(() => tabState("Stake")).toBe("selected");
+
+ await userEvent.click(app.getByTestId("number-input"));
+ await userEvent.keyboard("0.1");
+ await userEvent.click(
+ app.getByRole("button", { exact: true, name: "Stake" }).last()
+ );
+ await userEvent.click(
+ app.getByRole("button", { exact: true, name: "Confirm" })
+ );
+ await expect.poll(signatureRequests).toBe(1);
+ await expect.element(steps).toBeInTheDocument();
+
+ // Pressing a category asks first; the question outlasts the page exit.
+ await userEvent.click(tab("DeFi"));
+ await expect.element(dialog).toBeInTheDocument();
+ await waitForMs(pastPageExitMs);
+ expect(dialog.query()).not.toBeNull();
+ expect(steps.query()).not.toBeNull();
+ expect(tabState("Stake")).toBe("selected");
+ expect(tabState("DeFi")).toBe("default");
+
+ // Stay keeps the signing stake and drops the pressed category for good.
+ await userEvent.click(app.getByRole("button", { name: "Stay" }));
+ await expect.element(dialog).not.toBeInTheDocument();
+ await waitForMs(pastPageExitMs);
+ expect(dialog.query()).toBeNull();
+ expect(steps.query()).not.toBeNull();
+ expect(tabState("Stake")).toBe("selected");
+ expect(tabState("DeFi")).toBe("default");
+ expect(signatureRequests()).toBe(1);
+
+ // A category left pending by Stay would swallow this second press.
+ await userEvent.click(tab("DeFi"));
+ await expect.element(dialog).toBeInTheDocument();
+ await userEvent.click(app.getByRole("button", { name: "Leave" }));
+ await expect.poll(() => tabState("DeFi")).toBe("selected");
+ expect(tabState("Stake")).toBe("default");
+ await expect.element(steps).not.toBeInTheDocument();
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(app.getByTestId("number-input")).toBeInTheDocument();
+ expect(signatureRequests()).toBe(1);
+
+ await app.unmount();
+ });
});
diff --git a/packages/widget/tests/app/journeys/staking-flow/setup.ts b/packages/widget/tests/app/journeys/staking-flow/setup.ts
index 8c25c779d..a93bd5dc6 100644
--- a/packages/widget/tests/app/journeys/staking-flow/setup.ts
+++ b/packages/widget/tests/app/journeys/staking-flow/setup.ts
@@ -23,7 +23,13 @@ import { waitForMs } from "../../../utils/wait";
type TransactionDto = typeof LegacyTransaction.Encoded;
-export const setup = async (worker: TestWorker) => {
+export const setup = async (
+ worker: TestWorker,
+ {
+ /** Also lists an AVAX lending yield, so the DeFi category is available. */
+ includeDefiYield = false,
+ }: { readonly includeDefiYield?: boolean } = {}
+) => {
const token: TokenDto = {
name: "Avalanche C Chain",
symbol: "AVAX",
@@ -166,6 +172,21 @@ export const setup = async (worker: TestWorker) => {
supportsLedgerWalletApi: yieldOp.metadata.supportsLedgerWalletApi,
},
});
+ const defiYieldOp = yieldApiYieldDtoFixture({
+ ...yieldApiYieldOp,
+ id: "avalanche-avax-lending",
+ metadata: {
+ ...yieldApiYieldOp.metadata,
+ name: "AVAX Lending",
+ },
+ mechanics: {
+ ...yieldApiYieldOp.mechanics,
+ type: "lending",
+ },
+ });
+ const catalogYields = includeDefiYield
+ ? [yieldApiYieldOp, defiYieldOp]
+ : [yieldApiYieldOp];
const enterAction = {
id: "18bdda99-346a-4694-af71-58dfea68d542",
integrationId: "avalanche-avax-liquid-staking",
@@ -307,10 +328,10 @@ export const setup = async (worker: TestWorker) => {
await mockDelay();
return HttpResponse.json({
- items: [yieldApiYieldOp],
- total: 1,
+ items: catalogYields,
+ total: catalogYields.length,
offset: 0,
- limit: 1,
+ limit: catalogYields.length,
});
}),
http.get(
@@ -410,6 +431,15 @@ export const setup = async (worker: TestWorker) => {
})
);
+ if (includeDefiYield) {
+ worker.use(
+ http.get(yieldApiRoute(`/v1/yields/${defiYieldOp.id}`), async () => {
+ await mockDelay();
+ return HttpResponse.json(defiYieldOp);
+ })
+ );
+ }
+
const account = "0xB6c5273e79E2aDD234EBC07d87F3824e0f94B2F7";
const requestFn = vitest.fn(async ({ method }: { method: string }) => {
diff --git a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx
index 9490f0ecc..2bebf1d77 100644
--- a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx
+++ b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx
@@ -6,31 +6,42 @@ import {
useLocation,
} from "react-router";
import { describe, expect, it } from "vitest";
+import {
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../../src/services/navigation/widget-routes";
import { render } from "../../../utils/test-utils.dom.tsx";
/**
- * Redirect contract for compact position-details mounts.
+ * Redirect contract for Compact position-details mounts: relative redirects
+ * under the registry's absolute route patterns.
* Keep in sync with `src/app/layouts/compact/compact-routes.tsx`.
*/
const CompactPositionDetailsRedirectRoutes = () => (
-
+
hub} />
-
+
} />
} />
-
+
} />
- } />
+ }
+ />
} />
-
+
} />
- } />
+ }
+ />
} />
diff --git a/packages/widget/tests/app/layouts/layout-routes.test.tsx b/packages/widget/tests/app/layouts/layout-routes.test.tsx
new file mode 100644
index 000000000..4b12fdafd
--- /dev/null
+++ b/packages/widget/tests/app/layouts/layout-routes.test.tsx
@@ -0,0 +1,104 @@
+import {
+ createRoutesFromElements,
+ matchRoutes,
+ type RouteObject,
+} from "react-router";
+import { describe, expect, it } from "vitest";
+import { createCompactRoutes } from "../../../src/app/layouts/compact/compact-routes";
+import { createDashboardRoutes } from "../../../src/app/layouts/dashboard/dashboard-routes";
+import type { WidgetLayout } from "../../../src/services/config/widget-config-model";
+import {
+ widgetBorrowFlowRoutes,
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../src/services/navigation/widget-routes";
+
+type RegistryRoute = Readonly<{
+ readonly build: (...args: ReadonlyArray) => string;
+ readonly descendants: string;
+ readonly layouts: ReadonlyArray;
+ readonly paramNames: ReadonlyArray;
+ readonly pattern: string;
+}>;
+
+const registryRoutes: ReadonlyArray = [
+ ...Object.values(widgetRoutes),
+ ...[
+ ...Object.values(widgetYieldFlowRoutes),
+ ...Object.values(widgetBorrowFlowRoutes),
+ ].flatMap((flow) => [flow.review, flow.steps, flow.complete]),
+] as ReadonlyArray;
+
+const layoutRoutes: Readonly>> =
+ {
+ Compact: createRoutesFromElements(createCompactRoutes()),
+ Dashboard: createRoutesFromElements(
+ createDashboardRoutes({ registerEarnFooterButton: false })
+ ),
+ };
+
+const samplePath = (route: RegistryRoute, layout: WidgetLayout) =>
+ route.build(
+ layout,
+ Object.fromEntries(route.paramNames.map((name) => [name, `sample-${name}`]))
+ );
+
+/**
+ * The registry route a path renders in a Layout's tree: the deepest matched
+ * route with a path, or the root for index routes under pathless layouts.
+ * Null when only a catch-all matches.
+ */
+const mountedPattern = (
+ routes: ReadonlyArray,
+ path: string
+): string | null => {
+ const matches = matchRoutes([...routes], path) ?? [];
+ if (matches.length === 0) return null;
+ const withPath = matches.filter((match) => match.route.path !== undefined);
+ const deepest = withPath.at(-1)?.route.path ?? widgetRoutes.earn.pattern;
+ return deepest === "*" ? null : deepest;
+};
+
+const treePaths = (routes: ReadonlyArray): ReadonlyArray =>
+ routes.flatMap((route) => [
+ ...(route.path === undefined ? [] : [route.path]),
+ ...treePaths(route.children ?? []),
+ ]);
+
+describe.each(["Compact", "Dashboard"] as const)(
+ "%s Layout routes",
+ (layout) => {
+ const routes = layoutRoutes[layout];
+
+ it.each(
+ registryRoutes
+ .filter((route) => route.layouts.includes(layout))
+ .map((route) => [route.pattern, route] as const)
+ )("mounts %s", (_pattern, route) => {
+ expect([route.pattern, route.descendants]).toContain(
+ mountedPattern(routes, samplePath(route, layout))
+ );
+ });
+
+ it.each(
+ registryRoutes
+ .filter((route) => !route.layouts.includes(layout))
+ .map((route) => [route.pattern, route] as const)
+ )("does not mount %s", (_pattern, route) => {
+ expect([route.pattern, route.descendants]).not.toContain(
+ mountedPattern(routes, samplePath(route, layout))
+ );
+ });
+
+ it("mounts only registry patterns", () => {
+ const known = new Set([
+ "*",
+ ...registryRoutes.flatMap((route) => [
+ route.pattern,
+ route.descendants,
+ ]),
+ ]);
+ expect(treePaths(routes).filter((path) => !known.has(path))).toEqual([]);
+ });
+ }
+);
diff --git a/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx b/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx
index 5cc407fea..573aa22b5 100644
--- a/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx
+++ b/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { BorrowFeatureRoute } from "../../../src/app/routes/ui/borrow-feature-route";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
import { render } from "../../utils/test-utils.dom.tsx";
import { TestWidgetConfigProvider } from "../../utils/widget-config-provider";
@@ -38,10 +39,20 @@ const renderRoute = ({
>
- }>
+
+ }
+ >
} />
- }>
+
+ }
+ >
} />
} />
@@ -88,7 +99,11 @@ describe("BorrowFeatureRoute", () => {
>
- }>
+
+ }
+ >
}
diff --git a/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts b/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts
index 30a2c33df..8c64449b8 100644
--- a/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts
+++ b/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts
@@ -5,7 +5,7 @@ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
import { describe, expect, it, vi } from "vitest";
import { tabResourcesPrefetchAtom } from "../../../src/app/routes/state/tab-resources-prefetch";
import { appRuntime } from "../../../src/app/runtime/app-runtime";
-import { applicationRouterPathnameAtom } from "../../../src/app/runtime/application-router";
+import { applicationRouterLocationAtom } from "../../../src/app/runtime/application-router";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
walletConfigResultAtom,
@@ -68,7 +68,7 @@ const makeRegistry = ({
AtomRegistry.make({
initialValues: [
applicationRuntimeInitInitialValue(),
- Atom.initialValue(applicationRouterPathnameAtom, pathname),
+ Atom.initialValue(applicationRouterLocationAtom, { pathname }),
Atom.initialValue(
appRuntime.layer,
Layer.mergeAll(
diff --git a/packages/widget/tests/app/runtime/application-router.test.ts b/packages/widget/tests/app/runtime/application-router.test.ts
index 1705727fc..db4f8b3bf 100644
--- a/packages/widget/tests/app/runtime/application-router.test.ts
+++ b/packages/widget/tests/app/runtime/application-router.test.ts
@@ -5,14 +5,17 @@ import { applicationRoutes } from "../../../src/app/routes/application-routes";
import { appRuntime } from "../../../src/app/runtime/app-runtime";
import {
applicationRouterAtom,
- applicationRouterPathnameAtom,
+ applicationRouterLocationAtom,
} from "../../../src/app/runtime/application-router";
import { applicationRuntimeInitAtom } from "../../../src/app/runtime/application-runtime-init";
import {
- toWidgetPath,
WidgetNavigation,
type WidgetNavigationCommand,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../src/services/navigation/widget-routes";
const navigationCommandAtom = appRuntime
.fn(
@@ -50,21 +53,23 @@ describe("ApplicationRouter runtime", () => {
}
});
- it("publishes pathname changes through the ApplicationRouter stream", async () => {
+ it("publishes location changes through the ApplicationRouter stream", async () => {
const registry = makeRegistry();
- const unmount = registry.mount(applicationRouterPathnameAtom);
+ const unmount = registry.mount(applicationRouterLocationAtom);
try {
- expect(registry.get(applicationRouterPathnameAtom)).toBe("/");
+ expect(registry.get(applicationRouterLocationAtom).pathname).toBe("/");
registry.set(navigationCommandAtom, {
_tag: "Push",
- path: toWidgetPath("/positions"),
+ to: widgetRoutes.positions.build(),
scroll: "preserve",
});
await vi.waitFor(() =>
- expect(registry.get(applicationRouterPathnameAtom)).toBe("/positions")
+ expect(registry.get(applicationRouterLocationAtom).pathname).toBe(
+ "/positions"
+ )
);
} finally {
unmount();
@@ -112,7 +117,7 @@ describe("ApplicationRouter runtime", () => {
registry.set(navigationCommandAtom, {
_tag: "Push",
- path: toWidgetPath("/review"),
+ to: widgetYieldFlowRoutes.Earn.review.build(),
scroll: "preserve",
});
@@ -122,7 +127,7 @@ describe("ApplicationRouter runtime", () => {
registry.set(navigationCommandAtom, {
_tag: "Replace",
- path: toWidgetPath("/complete"),
+ to: widgetYieldFlowRoutes.Earn.complete.build(),
scroll: "preserve",
});
@@ -148,7 +153,7 @@ describe("ApplicationRouter runtime", () => {
try {
firstRegistry.set(navigationCommandAtom, {
_tag: "Push",
- path: toWidgetPath("/review"),
+ to: widgetYieldFlowRoutes.Earn.review.build(),
scroll: "preserve",
});
await vi.waitFor(() =>
diff --git a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts
index 44d1c99d3..252859cfb 100644
--- a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts
+++ b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts
@@ -1,20 +1,27 @@
import { describe, expect, it, vi } from "@effect/vitest";
-import { Effect, Layer, Schema, Stream } from "effect";
+import { Effect, Layer, Option, Schema, Stream } from "effect";
import {
DeepLinkCoordinator,
type DeepLinkRouteObservation,
} from "../../../src/app/runtime/deep-link-coordinator";
import { EarnBalance } from "../../../src/domain/earn/models";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import { findPendingActionDeepLinkTarget } from "../../../src/features/earn/model/pending-action-deep-link";
-import type { YieldFlowSession } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
+import { yieldFlowRoutes } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
import {
type WidgetNavigationCommand,
WidgetNavigationError,
type WidgetNavigationOptions,
- type WidgetPath,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ type WidgetPath,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import {
disconnectedLedgerConnectorState,
disconnectedNormalizedWalletState,
@@ -32,6 +39,7 @@ const otherAddress = Schema.decodeSync(WalletAddress)(
"0x0000000000000000000000000000000000000002"
);
const scope = new WalletScopeKey({ address, network: "ethereum" });
+const yieldId = Schema.decodeSync(YieldId)("yield-1");
const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({
maintenance: false,
@@ -42,7 +50,7 @@ const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({
mount: {
_tag: "PositionManage",
balanceId: "balance-1",
- integrationId: "yield-1",
+ integrationId: yieldId,
},
},
intent: {
@@ -89,7 +97,9 @@ const makeCoordinatorTestKit = Effect.fn("makeCoordinatorTestKit")(function* ({
});
const navigation = yield* makeTestNavigation({
execute: (command) =>
- command._tag === "Push" ? navigate(command.path, command) : Effect.void,
+ command._tag === "Push"
+ ? navigate(widgetTargetPath(command.to), command)
+ : Effect.void,
});
return {
@@ -102,19 +112,25 @@ const makeCoordinatorTestKit = Effect.fn("makeCoordinatorTestKit")(function* ({
} as const;
});
-/** The navigation state Start carries to Review. */
-type YieldFlowNavigationState = Readonly<{
- readonly yieldFlowSession?: YieldFlowSession;
-}>;
-
/** The Flow Sessions Start navigated to Review with. */
const startedSessions = (commands: ReadonlyArray) =>
- commands.flatMap((command) => {
- const state = command.state as YieldFlowNavigationState | undefined;
- return command._tag === "Push" && state?.yieldFlowSession
- ? [{ path: command.path, session: state.yieldFlowSession }]
- : [];
- });
+ commands.flatMap((command) =>
+ command._tag === "Push"
+ ? yieldFlowRoutes.PositionManage.state(
+ widgetTargetState(command.to)
+ ).pipe(
+ Option.match({
+ onNone: () => [],
+ onSome: ({ yieldFlowSession }) => [
+ {
+ path: widgetTargetPath(command.to),
+ session: yieldFlowSession,
+ },
+ ],
+ })
+ )
+ : []
+ );
const runObservations = Effect.fn("runObservations")(function* (
observations: ReadonlyArray,
@@ -238,7 +254,7 @@ describe("DeepLinkCoordinator", () => {
const position = {
maintenance: false,
pendingAction: null,
- position: { balanceId: "balance-1", yieldId: "yield-1" },
+ position: { balanceId: "balance-1", yieldId },
ready: true,
} satisfies DeepLinkRouteObservation;
@@ -270,7 +286,7 @@ describe("DeepLinkCoordinator", () => {
{
maintenance: false,
pendingAction: null,
- position: { balanceId: "balance-1", yieldId: "yield-1" },
+ position: { balanceId: "balance-1", yieldId },
ready: true,
},
],
diff --git a/packages/widget/tests/features/activity/activity-routes.test.tsx b/packages/widget/tests/features/activity/activity-routes.test.tsx
index 85514a1aa..73bfe2676 100644
--- a/packages/widget/tests/features/activity/activity-routes.test.tsx
+++ b/packages/widget/tests/features/activity/activity-routes.test.tsx
@@ -2,6 +2,10 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { createRoutesFromElements, type RouteObject } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { createActivityActionRoutes } from "../../../src/features/activity/composition";
+import {
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../src/services/navigation/widget-routes";
vi.mock("../../../src/features/activity/react/activity-action-route", () => ({
ActivityActionRoute: function ActivityActionRoute() {
@@ -75,7 +79,7 @@ const borrowActionIdRoute = (actionRoute: string): RouteDescription => ({
],
element: actionRoute,
index: false,
- path: "borrow/:actionId",
+ path: widgetRoutes.activityBorrowAction.pattern,
});
const actionIdRoute = (actionRoute: string): RouteDescription => ({
@@ -92,13 +96,13 @@ const actionIdRoute = (actionRoute: string): RouteDescription => ({
children: [],
element: "YieldActionContinuationStepsPage",
index: false,
- path: "steps",
+ path: widgetYieldFlowRoutes.YieldActionContinuation.steps.pattern,
},
{
children: [],
element: "YieldActionContinuationCompletePage",
index: false,
- path: "complete",
+ path: widgetYieldFlowRoutes.YieldActionContinuation.complete.pattern,
},
],
element: "YieldActionContinuationExecution",
@@ -108,7 +112,7 @@ const actionIdRoute = (actionRoute: string): RouteDescription => ({
],
element: actionRoute,
index: false,
- path: ":actionId",
+ path: widgetRoutes.activityAction.pattern,
});
const ActionScopeGuard = function ActionScopeGuard() {
diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx
index 595831f4a..c32403bdc 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx
+++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx
@@ -28,7 +28,6 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
BorrowFlowSession,
type BorrowTransactionFlowReview,
- makeBorrowFlowNavigationState,
} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
import {
BorrowTransactionFlowExecutionPage,
@@ -48,6 +47,11 @@ import { BorrowOperations } from "../../../src/services/api/operations";
import { ApiRequestError } from "../../../src/services/api/resource-sources";
import { WidgetConfigService } from "../../../src/services/config/widget-config";
import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetRoutes,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { createWidgetI18nInstance } from "../../../src/services/translation/widget-translation";
import {
WalletOwnerMismatchError,
@@ -385,15 +389,17 @@ const renderExecution = (
Effect.sync(() => {
navigation.current?.(-1);
}),
- push: (path, options) =>
+ push: (to) =>
Effect.sync(() => {
- navigation.current?.(path, { state: options?.state });
+ navigation.current?.(widgetTargetPath(to), {
+ state: widgetTargetState(to),
+ });
}),
- replace: (path, options) =>
+ replace: (to) =>
Effect.sync(() => {
- navigation.current?.(path, {
+ navigation.current?.(widgetTargetPath(to), {
replace: true,
- state: options?.state,
+ state: widgetTargetState(to),
});
}),
});
@@ -444,7 +450,7 @@ const renderExecution = (
flowWallet.layer
);
// Start hands its Session to the flow route through navigation state.
- const flowState = makeBorrowFlowNavigationState(options.session ?? session);
+ const flowState = { borrowFlowSession: options.session ?? session };
const initialEntries = (
options.initialEntries ?? [options.initialPath ?? "/borrow/review"]
).map((pathname) =>
@@ -470,7 +476,7 @@ const renderExecution = (
}
diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts
index 7d3d7d73e..137b32b48 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts
+++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts
@@ -22,8 +22,8 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
BorrowFlowSession,
type BorrowTransactionFlowIntake,
- decodeBorrowFlowNavigationState,
- makeBorrowFlowNavigationState,
+ borrowFlowRoutes,
+ getBorrowTransactionFlowDestination,
} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
import {
leaveBorrowFlow,
@@ -35,11 +35,15 @@ import { BorrowOperations } from "../../../src/services/api/operations";
import { ApiRequestError } from "../../../src/services/api/resource-sources";
import { WidgetConfigService } from "../../../src/services/config/widget-config";
import {
- toWidgetPath,
type WidgetNavigation,
type WidgetNavigationCommand,
WidgetNavigationError,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetRoutes,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model";
import {
type TransactionWorkflowInput,
@@ -104,6 +108,13 @@ const marketPositionIntake: BorrowTransactionFlowIntake = {
entry: { _tag: "MarketPosition", marketId },
};
+const borrowEntryDestination = getBorrowTransactionFlowDestination(
+ borrowEntryIntake.entry
+);
+const marketPositionDestination = getBorrowTransactionFlowDestination(
+ marketPositionIntake.entry
+);
+
const makeAction = (status = "CREATED", id = "action-1"): Action =>
Schema.decodeUnknownSync(Action)({
action: "borrow",
@@ -204,7 +215,12 @@ const makeKit = Effect.fn("test.makeBorrowFlowKit")(function* (
// compare routes only.
const withoutNavigationState = (
commands: ReadonlyArray
-) => commands.map(({ state: _state, ...command }) => command);
+) =>
+ commands.map((command) =>
+ command._tag === "Back"
+ ? command
+ : { _tag: command._tag, path: widgetTargetPath(command.to) }
+ );
// The action id Steps' navigation state carries after a handoff.
const handedOffActionId = (session: BorrowFlowSession) =>
@@ -238,20 +254,25 @@ describe("startBorrowTransactionFlow", () => {
expect(
(yield* kit.navigation.commands).map((command) => ({
_tag: command._tag,
- path: command._tag === "Back" ? null : command.path,
- session: Option.getOrNull(
- decodeBorrowFlowNavigationState(command.state)
- ),
+ path: command._tag === "Back" ? null : widgetTargetPath(command.to),
+ session:
+ command._tag === "Back"
+ ? null
+ : Option.getOrNull(
+ borrowFlowRoutes.BorrowEntry.state(
+ widgetTargetState(command.to)
+ ).pipe(Option.map((state) => state.borrowFlowSession))
+ ),
}))
).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/borrow/review"),
+ path: borrowEntryDestination.reviewPath,
session: first.session,
},
{
_tag: "Push",
- path: toWidgetPath("/borrow/review"),
+ path: borrowEntryDestination.reviewPath,
session: second.session,
},
]);
@@ -294,7 +315,7 @@ describe("startBorrowTransactionFlow", () => {
expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([
{
_tag: "Push",
- path: toWidgetPath(`/positions/borrow/${marketId}/review`),
+ path: marketPositionDestination.reviewPath,
},
]);
expect(yield* kit.tracking.trackedEvents).toEqual([]);
@@ -346,7 +367,7 @@ describe("startBorrowTransactionFlow", () => {
expect(error).toMatchObject({ _tag: "WidgetNavigationError" });
expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([
- { _tag: "Push", path: toWidgetPath("/borrow/review") },
+ { _tag: "Push", path: borrowEntryDestination.reviewPath },
]);
expect(yield* kit.tracking.trackedEvents).toEqual([]);
})
@@ -417,8 +438,10 @@ describe("confirmBorrowAction", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/borrow/steps"),
- state: makeBorrowFlowNavigationState(handle, action.id),
+ to: borrowEntryDestination.steps({
+ borrowFlowSession: handle,
+ executionActionId: action.id,
+ }),
},
]);
})
@@ -435,11 +458,10 @@ describe("confirmBorrowAction", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath(`/positions/borrow/${marketId}/steps`),
- state: makeBorrowFlowNavigationState(
- handle,
- yield* handedOffActionId(handle)
- ),
+ to: marketPositionDestination.steps({
+ borrowFlowSession: handle,
+ executionActionId: yield* handedOffActionId(handle),
+ }),
},
]);
})
@@ -859,7 +881,7 @@ describe("leaveBorrowFlow", () => {
it.effect("replaces the flow with Borrow for a Borrow Entry flow", () =>
Effect.gen(function* () {
expect(yield* leave(borrowEntryIntake)).toEqual([
- { _tag: "Replace", path: toWidgetPath("/borrow") },
+ { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") },
]);
})
);
@@ -871,7 +893,7 @@ describe("leaveBorrowFlow", () => {
expect(yield* leave(marketPositionIntake)).toEqual([
{
_tag: "Replace",
- path: toWidgetPath(`/positions/borrow/${marketId}`),
+ to: widgetRoutes.borrowPosition.build("Dashboard", { marketId }),
},
]);
})
@@ -903,7 +925,7 @@ describe("leaveBorrowFlow", () => {
expect(interruptedWhileLeaving).toBeUndefined();
expect(yield* kit.navigation.commands).toEqual([
- { _tag: "Replace", path: toWidgetPath("/borrow") },
+ { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") },
]);
})
);
diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts
index 41dd5f252..ce2db0814 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts
+++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts
@@ -19,7 +19,7 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
BorrowFlowSession,
type BorrowTransactionFlowIntake,
- makeBorrowFlowNavigationState,
+ getBorrowTransactionFlowDestination,
} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
import { emptyBorrowExecutionView } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-workflow";
import { makeBorrowFlowExecutionPage } from "../../../src/features/borrow-transaction-flow/state/execution-page";
@@ -30,7 +30,7 @@ import {
} from "../../../src/features/borrow-transaction-flow/state/review-page";
import { BorrowOperations } from "../../../src/services/api/operations";
import { ApiRequestError } from "../../../src/services/api/resource-sources";
-import { toWidgetPath } from "../../../src/services/navigation/widget-navigation";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model";
import type { TransactionWorkflowCommand } from "../../../src/services/transaction-workflow/transaction-workflow-model";
import { TransactionWorkflowService } from "../../../src/services/transaction-workflow/transaction-workflow-service";
@@ -202,8 +202,10 @@ describe("Borrow flow Review page", () => {
expect(yield* navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/borrow/steps"),
- state: makeBorrowFlowNavigationState(handle, action.id),
+ to: getBorrowTransactionFlowDestination(intake.entry).steps({
+ borrowFlowSession: handle,
+ executionActionId: action.id,
+ }),
},
]);
})
@@ -311,7 +313,7 @@ describe("Borrow flow Review page", () => {
});
expect(yield* navigation.commands).toEqual([
- { _tag: "Replace", path: toWidgetPath("/borrow") },
+ { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") },
]);
})
);
@@ -401,7 +403,7 @@ describe("Borrow flow Execution page", () => {
});
expect(yield* navigation.commands).toEqual([
- { _tag: "Replace", path: toWidgetPath("/borrow") },
+ { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") },
]);
})
);
diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx
index a3802cd60..e1c09e681 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx
+++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx
@@ -16,7 +16,6 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
BorrowFlowSession,
type BorrowTransactionFlowEntry,
- makeBorrowFlowNavigationState,
} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
import {
BorrowTransactionFlowRoute,
@@ -175,7 +174,7 @@ describe("Borrow Flow route", () => {
const flow = await renderFlow({
entry: {
pathname: "/positions/borrow/market-b/review",
- state: makeBorrowFlowNavigationState(session),
+ state: { borrowFlowSession: session },
},
expected: "MarketPosition",
});
@@ -198,7 +197,7 @@ describe("Borrow Flow route", () => {
connectedWalletScope,
entry: {
pathname: "/positions/borrow/market-a/review",
- state: makeBorrowFlowNavigationState(session),
+ state: { borrowFlowSession: session },
},
expected: "MarketPosition",
});
@@ -213,7 +212,7 @@ describe("Borrow Flow route", () => {
const flow = await renderFlow({
entry: {
pathname: "/borrow/review",
- state: makeBorrowFlowNavigationState(session),
+ state: { borrowFlowSession: session },
},
expected: "BorrowEntry",
});
@@ -241,16 +240,13 @@ describe("Borrow Flow route", () => {
const flow = await renderFlow({
entry: {
pathname: "/borrow/review",
- state: makeBorrowFlowNavigationState(first),
+ state: { borrowFlowSession: first },
},
expected: "BorrowEntry",
});
await expect.poll(flow.page).toBe("Review:1");
- await flow.navigate(
- "/borrow/review",
- makeBorrowFlowNavigationState(second)
- );
+ await flow.navigate("/borrow/review", { borrowFlowSession: second });
await expect.poll(flow.page).toBe("Review:2");
await flow.navigate("/borrow/steps");
diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx
index 1b92bb5f9..a95591380 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx
+++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx
@@ -2,6 +2,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { createRoutesFromElements, type RouteObject } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { createBorrowTransactionFlowRoutes } from "../../../src/features/borrow-transaction-flow/composition";
+import { widgetBorrowFlowRoutes } from "../../../src/services/navigation/widget-routes";
vi.mock(
"../../../src/features/borrow-transaction-flow/react/borrow-flow-route",
@@ -70,24 +71,26 @@ const describeRoute = (route: RouteObject): RouteContract => ({
path: route.path ?? null,
});
-const flowPagesContract: ReadonlyArray = [
+const flowPagesContract = (
+ entry: keyof typeof widgetBorrowFlowRoutes
+): ReadonlyArray => [
{
children: [],
element: "BorrowReviewPage",
- path: "review",
+ path: widgetBorrowFlowRoutes[entry].review.pattern,
},
{
children: [
{
children: [],
element: "BorrowTransactionFlowExecutionPage(Steps)>BorrowStepsPage",
- path: "steps",
+ path: widgetBorrowFlowRoutes[entry].steps.pattern,
},
{
children: [],
element:
"BorrowTransactionFlowExecutionPage(Complete)>BorrowCompletePage",
- path: "complete",
+ path: widgetBorrowFlowRoutes[entry].complete.pattern,
},
],
element: "BorrowTransactionFlowExecutionRoute",
@@ -107,7 +110,7 @@ describe("Borrow Transaction Flow routes", () => {
expect(routes.map(describeRoute)).toEqual([
{
- children: flowPagesContract,
+ children: flowPagesContract(entry),
element: `BorrowTransactionFlowRoute(${entry})`,
path: null,
},
@@ -127,7 +130,7 @@ describe("Borrow Transaction Flow routes", () => {
{
children: [
{
- children: flowPagesContract,
+ children: flowPagesContract("MarketPosition"),
element: "BorrowFlowLayout",
path: null,
},
diff --git a/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx
index 38f541716..3f79fbf7b 100644
--- a/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx
+++ b/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx
@@ -11,10 +11,7 @@ import {
WalletAddress,
} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
-import {
- BorrowFlowSession,
- makeBorrowFlowNavigationState,
-} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
+import { BorrowFlowSession } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow";
import {
BorrowTransactionFlowExecutionPage,
BorrowTransactionFlowExecutionRoute,
@@ -28,6 +25,10 @@ import {
makeWidgetNavigation,
WidgetNavigation,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model";
import {
TransactionWorkflowInputError,
@@ -157,20 +158,24 @@ const renderFlow = async ({ invalidInput = false, pathname }: FlowOptions) => {
],
{
initialEntries: [
- withUnserializedState(
- { pathname },
- makeBorrowFlowNavigationState(session)
- ),
+ withUnserializedState({ pathname }, { borrowFlowSession: session }),
],
}
);
const navigation = makeWidgetNavigation({
back: () => Effect.promise(() => router.navigate(-1)),
- push: (path, options) =>
- Effect.promise(() => router.navigate(path, { state: options?.state })),
- replace: (path, options) =>
+ push: (to) =>
+ Effect.promise(() =>
+ router.navigate(widgetTargetPath(to), {
+ state: widgetTargetState(to),
+ })
+ ),
+ replace: (to) =>
Effect.promise(() =>
- router.navigate(path, { replace: true, state: options?.state })
+ router.navigate(widgetTargetPath(to), {
+ replace: true,
+ state: widgetTargetState(to),
+ })
),
});
const layer = Layer.mergeAll(
diff --git a/packages/widget/tests/features/borrow/borrow-routes.test.tsx b/packages/widget/tests/features/borrow/borrow-routes.test.tsx
index 89a138eb8..c73d5392d 100644
--- a/packages/widget/tests/features/borrow/borrow-routes.test.tsx
+++ b/packages/widget/tests/features/borrow/borrow-routes.test.tsx
@@ -7,6 +7,7 @@ import {
} from "../../../src/features/borrow/composition";
import { createBorrowTransactionFlowRoutes } from "../../../src/features/borrow-transaction-flow/composition";
import { WalletScopeRouteGuard } from "../../../src/features/wallet/composition";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
const routeComposition = {
WalletScopeRouteGuard,
@@ -143,13 +144,13 @@ describe("Borrow routes", () => {
],
element: "BorrowShell",
index: false,
- path: "borrow",
+ path: widgetRoutes.borrow.pattern,
},
{
children: [],
- element: "Navigate",
+ element: "WidgetNavigate",
index: false,
- path: "borrow/*",
+ path: widgetRoutes.borrow.descendants,
},
]);
});
@@ -176,7 +177,7 @@ describe("Borrow routes", () => {
children: [],
element: "BorrowPositionActionPage",
index: false,
- path: "action/:actionId",
+ path: widgetRoutes.borrowPositionAction.pattern,
},
{
children: [],
@@ -187,7 +188,7 @@ describe("Borrow routes", () => {
],
element: "BorrowPositionDetailsPage",
index: false,
- path: "borrow/:marketId",
+ path: widgetRoutes.borrowPosition.pattern,
},
],
element: "BorrowConnectedWalletRoute",
@@ -201,9 +202,9 @@ describe("Borrow routes", () => {
},
{
children: [],
- element: "Navigate",
+ element: "WidgetNavigate",
index: false,
- path: "borrow/*",
+ path: widgetRoutes.positionsBorrow.descendants,
},
]);
});
diff --git a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx
index baff35bb7..7c34b6785 100644
--- a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx
+++ b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx
@@ -5,7 +5,10 @@ import { act, type ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { SKAtomRegistryProvider } from "../../../src/app/composition/providers/atom-runtime";
import { applicationRoutes } from "../../../src/app/routes/application-routes";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
PositionDetailsWorkflowKey,
@@ -22,7 +25,7 @@ const settings = {
const positionWorkflowAtom = positionDetailsWorkflowAtom(
new PositionDetailsWorkflowKey({
balanceId: "balance-1",
- integrationId: "yield-1",
+ integrationId: Schema.decodeSync(YieldId)("yield-1"),
pendingActionType: null,
scope: new WalletScopeKey({
address: Schema.decodeSync(WalletAddress)("0xwallet-a"),
diff --git a/packages/widget/tests/features/position-details/position-details-action-content.test.ts b/packages/widget/tests/features/position-details/position-details-action-content.test.ts
index c2192e72c..c4e2fa424 100644
--- a/packages/widget/tests/features/position-details/position-details-action-content.test.ts
+++ b/packages/widget/tests/features/position-details/position-details-action-content.test.ts
@@ -3,7 +3,6 @@ import { Schema } from "effect";
import { describe, expect, it } from "vitest";
import { EarnBalance } from "../../../src/domain/earn/models";
import {
- getPositionDetailsHubPath,
positionDetailsExitHasContent,
positionDetailsPendingHasContent,
resolvePositionDetailsPageLayout,
@@ -108,13 +107,4 @@ describe("position details action content", () => {
})
).toBe("InfoOnly");
});
-
- it("builds the position hub path", () => {
- expect(
- getPositionDetailsHubPath({
- balanceId: "balance-1",
- integrationId: "yield-1",
- })
- ).toBe("/positions/yield-1/balance-1");
- });
});
diff --git a/packages/widget/tests/features/position-details/position-details-exit-command.test.ts b/packages/widget/tests/features/position-details/position-details-exit-command.test.ts
index 8d2271542..8cbfe79e6 100644
--- a/packages/widget/tests/features/position-details/position-details-exit-command.test.ts
+++ b/packages/widget/tests/features/position-details/position-details-exit-command.test.ts
@@ -24,8 +24,8 @@ import {
} from "../../../src/features/position-details/state/workflow";
import { walletConnectionStateAtom } from "../../../src/features/wallet/index";
import {
- decodeYieldFlowNavigationState,
type YieldTransactionFlowIntake,
+ yieldFlowRoutes,
} from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
import {
YieldOpportunityKey,
@@ -39,8 +39,12 @@ import {
import {
makeWidgetNavigation,
type WidgetNavigationOptions,
- type WidgetPath,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import type { TrackingService } from "../../../src/services/tracking/tracking-service";
import {
disconnectedLedgerConnectorState,
@@ -56,7 +60,10 @@ import { makeTestTracking } from "../../utils/services/tracking-service";
import { makeTestNavigation } from "../../utils/services/widget-navigation";
import { makeYieldFlowTestKit } from "../../utils/yield-flow-test-kit";
-type Push = (path: WidgetPath, options?: WidgetNavigationOptions) => void;
+type Push = (
+ to: WidgetNavigationTarget,
+ options?: WidgetNavigationOptions
+) => void;
/** The intake of the Session the single Start navigation carried to Review. */
const startedIntake = (
@@ -64,10 +71,14 @@ const startedIntake = (
reviewPath: string
): YieldTransactionFlowIntake => {
expect(push).toHaveBeenCalledOnce();
- const [path, options] = push.mock.calls[0] ?? [];
- expect(path).toBe(reviewPath);
- return Option.getOrThrow(decodeYieldFlowNavigationState(options?.state))
- .intake;
+ const [to] = push.mock.calls[0] ?? [];
+ if (to === undefined) throw new Error("Expected a Review navigation");
+ expect(widgetTargetPath(to)).toBe(reviewPath);
+ return Option.getOrThrow(
+ yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe(
+ Option.map((state) => state.yieldFlowSession.intake)
+ )
+ );
};
const {
diff --git a/packages/widget/tests/features/position-details/position-details-hub-model.test.ts b/packages/widget/tests/features/position-details/position-details-hub-model.test.ts
index c6249b6fd..b5b6ae76f 100644
--- a/packages/widget/tests/features/position-details/position-details-hub-model.test.ts
+++ b/packages/widget/tests/features/position-details/position-details-hub-model.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
- getPositionDetailsHubPath,
positionDetailsExitHasContent,
positionDetailsHubHasContent,
resolvePositionDetailsActionCapabilities,
@@ -11,15 +10,6 @@ import {
import { yieldApiYieldFixture } from "../../fixtures";
describe("position details hub model", () => {
- it("builds the bare position details hub path", () => {
- expect(
- getPositionDetailsHubPath({
- balanceId: "balance-1",
- integrationId: "yield-1",
- })
- ).toBe("/positions/yield-1/balance-1");
- });
-
it("defaults to unstake when exit is supported, else stake", () => {
expect(
resolvePositionDetailsActionMode({ canStake: true, canUnstake: true })
diff --git a/packages/widget/tests/features/position-details/position-details-workflow.test.ts b/packages/widget/tests/features/position-details/position-details-workflow.test.ts
index 99eea832b..2e34baa6c 100644
--- a/packages/widget/tests/features/position-details/position-details-workflow.test.ts
+++ b/packages/widget/tests/features/position-details/position-details-workflow.test.ts
@@ -2,7 +2,10 @@ import BigNumber from "bignumber.js";
import { Schema } from "effect";
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
import { describe, expect, it } from "vitest";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
makePositionDetailsWorkflowState,
@@ -27,7 +30,7 @@ const scopeB = new WalletScopeKey({
const workflowAtom = positionDetailsWorkflowAtom(
new PositionDetailsWorkflowKey({
balanceId: "balance-1",
- integrationId: "yield-1",
+ integrationId: Schema.decodeSync(YieldId)("yield-1"),
pendingActionType: null,
scope: scopeA,
})
@@ -74,7 +77,7 @@ describe("position details workflow atoms", () => {
const walletBWorkflow = positionDetailsWorkflowAtom(
new PositionDetailsWorkflowKey({
balanceId: "balance-1",
- integrationId: "yield-1",
+ integrationId: Schema.decodeSync(YieldId)("yield-1"),
pendingActionType: null,
scope: scopeB,
})
diff --git a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx
index 6b9757039..5234928d4 100644
--- a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx
+++ b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx
@@ -33,7 +33,7 @@ import {
walletConnectionStateAtom,
walletScopeAtom,
} from "../../../src/features/wallet/index";
-import { decodeYieldFlowNavigationState } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
+import { yieldFlowRoutes } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
import {
YieldOpportunityKey,
yieldOpportunityAtom,
@@ -45,6 +45,10 @@ import {
import { WidgetConfigService } from "../../../src/services/config/widget-config";
import { ApplicationRouter } from "../../../src/services/navigation/application-router";
import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import type { NormalizedWalletState } from "../../../src/services/wallet/wallet-state";
import { yieldApiYieldFixture } from "../../fixtures";
import { makeConnectedWalletState } from "../../fixtures/wallet-state";
@@ -82,11 +86,18 @@ const navigationChannel: {
} = { navigate: null };
const navigation = makeWidgetNavigation({
back: () => Effect.sync(() => navigationChannel.navigate?.(-1)),
- push: (path, options) =>
- Effect.sync(() => navigationChannel.navigate?.(path, options)),
- replace: (path, options) =>
+ push: (to) =>
+ Effect.sync(() =>
+ navigationChannel.navigate?.(widgetTargetPath(to), {
+ state: widgetTargetState(to),
+ })
+ ),
+ replace: (to) =>
Effect.sync(() =>
- navigationChannel.navigate?.(path, { ...options, replace: true })
+ navigationChannel.navigate?.(widgetTargetPath(to), {
+ replace: true,
+ state: widgetTargetState(to),
+ })
),
});
const makeYieldFlowLayer = () =>
@@ -135,7 +146,9 @@ const PositionEntry = () => {
};
const ReviewGuard = () => {
- const session = decodeYieldFlowNavigationState(useLocation().state);
+ const session = yieldFlowRoutes.PositionStake.state(useLocation().state).pipe(
+ Option.map((state) => state.yieldFlowSession)
+ );
const carriesPositionStake = Option.exists(
session,
({ intake, mount }) =>
diff --git a/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx b/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx
index cb660c043..aadebfda6 100644
--- a/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx
+++ b/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx
@@ -7,6 +7,7 @@ import type { Connector } from "wagmi";
import { WalletAddress } from "../../../src/domain/identity/identifiers";
import { useWalletScopeRoute } from "../../../src/features/wallet/index";
import { WalletScopeRoute } from "../../../src/features/wallet/react/wallet-scope-route";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
import type { NormalizedWalletState } from "../../../src/services/wallet/wallet-state";
import { disconnectedNormalizedWalletState } from "../../../src/services/wallet/wallet-state";
import { render } from "../../utils/test-utils.dom.tsx";
@@ -79,7 +80,7 @@ const TestRouter = ({
}
@@ -96,7 +97,10 @@ const TestRouter = ({
}
/>
- safe} />
+ safe}
+ />
diff --git a/packages/widget/tests/features/yield-entry/yield-entry.test.ts b/packages/widget/tests/features/yield-entry/yield-entry.test.ts
index 22f216056..e63b80341 100644
--- a/packages/widget/tests/features/yield-entry/yield-entry.test.ts
+++ b/packages/widget/tests/features/yield-entry/yield-entry.test.ts
@@ -16,14 +16,18 @@ import { makeYieldEntry } from "../../../src/features/yield-entry/index";
import { getYieldEntryCta } from "../../../src/features/yield-entry/model/yield-entry";
import type { YieldEntryFacadeInput } from "../../../src/features/yield-entry/state/yield-entry";
import {
- decodeYieldFlowNavigationState,
type YieldFlowSession,
+ yieldFlowRoutes,
} from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
import {
makeWidgetNavigation,
type WidgetNavigationOptions,
- type WidgetPath,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { WalletModal } from "../../../src/services/wallet/wallet-modal";
import {
disconnectedLedgerConnectorState,
@@ -184,16 +188,21 @@ const readSubmitOutcome = (
);
type Push = (
- path: WidgetPath,
+ to: WidgetNavigationTarget,
options?: WidgetNavigationOptions
) => Effect.Effect;
/** The Session the single Start navigation carried to Review. */
const startedSession = (push: Mock): YieldFlowSession => {
expect(push).toHaveBeenCalledOnce();
- const [path, options] = push.mock.calls[0] ?? [];
- expect(path).toBe("/review");
- return Option.getOrThrow(decodeYieldFlowNavigationState(options?.state));
+ const [to] = push.mock.calls[0] ?? [];
+ if (to === undefined) throw new Error("Expected a Review navigation");
+ expect(widgetTargetPath(to)).toBe("/review");
+ return Option.getOrThrow(
+ yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe(
+ Option.map((state) => state.yieldFlowSession)
+ )
+ );
};
describe("Yield Entry", () => {
@@ -485,7 +494,7 @@ describe("Yield Entry", () => {
registry.set(facade.submitAtom, undefined);
await readSubmitOutcome(registry, facade.submitAtom).toBe("submitted");
expect(ports.push).toHaveBeenCalledWith(
- "/review",
+ expect.objectContaining({ path: "/review" }),
expect.objectContaining({ _tag: "Push" })
);
expect(startedSession(ports.push)).toMatchObject({
diff --git a/packages/widget/tests/features/yield-flow-confidential.test.ts b/packages/widget/tests/features/yield-flow-confidential.test.ts
index 3a0aad6ee..041c82cea 100644
--- a/packages/widget/tests/features/yield-flow-confidential.test.ts
+++ b/packages/widget/tests/features/yield-flow-confidential.test.ts
@@ -18,7 +18,7 @@ import { appRuntime } from "../../src/app/runtime/app-runtime";
import { walletRuntime } from "../../src/app/runtime/wallet-runtime";
import type { DirectTransaction } from "../../src/domain/action/direct-transaction";
import { ConfidentialHandle } from "../../src/domain/earn/confidential";
-import { WalletAddress } from "../../src/domain/identity/identifiers";
+import { WalletAddress, YieldId } from "../../src/domain/identity/identifiers";
import { Token } from "../../src/domain/token/token";
import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope";
import {
@@ -108,7 +108,11 @@ const exitStart = (
unstakeToken: integration.token,
walletScope,
},
- mount: { _tag: "PositionExit", balanceId: "b", integrationId: "i" },
+ mount: {
+ _tag: "PositionExit",
+ balanceId: "b",
+ integrationId: Schema.decodeSync(YieldId)("i"),
+ },
};
};
@@ -134,7 +138,11 @@ const withdrawUnwrapStart = (): NavigatingYieldTransactionFlowStart => {
},
walletScope,
},
- mount: { _tag: "PositionManage", balanceId: "b", integrationId: "i" },
+ mount: {
+ _tag: "PositionManage",
+ balanceId: "b",
+ integrationId: Schema.decodeSync(YieldId)("i"),
+ },
};
};
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx
index 714a3af35..f92f19f33 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx
@@ -38,6 +38,11 @@ import {
import { WidgetConfigService } from "../../../src/services/config/widget-config";
import { ApplicationRouter } from "../../../src/services/navigation/application-router";
import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetRoutes,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model";
import {
makeTransactionSignError,
@@ -312,10 +317,13 @@ const FlowRoutes = ({
} />
+
}
>
- }>
+ }>
} />
}>
{
testNavigationChannel.navigate?.(-1);
}),
- push: (path, options) =>
+ push: (to) =>
Effect.sync(() => {
if (!testNavigationChannel.navigate) {
throw new Error("Test navigation bridge is unavailable");
}
- testNavigationChannel.navigate(path, { state: options?.state });
+ testNavigationChannel.navigate(widgetTargetPath(to), {
+ state: widgetTargetState(to),
+ });
}),
- replace: (path, options) =>
+ replace: (to) =>
Effect.sync(() => {
- testNavigationChannel.navigate?.(path, {
+ testNavigationChannel.navigate?.(widgetTargetPath(to), {
replace: true,
- state: options?.state,
+ state: widgetTargetState(to),
});
}),
});
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts
index 78fc3ebd6..4b25b89bb 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts
@@ -15,18 +15,17 @@ import type { YieldAction } from "../../../src/domain/action/models";
import {
TransactionWorkflowId,
WalletAddress,
+ YieldId,
} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
- decodeYieldFlowExecutionHandoff,
- decodeYieldFlowNavigationState,
makeYieldActionContinuationSession,
- makeYieldFlowNavigationState,
makeYieldFlowSession,
type NavigatingYieldTransactionFlowStart,
YieldFlowHandoff,
type YieldFlowSession,
type YieldTransactionFlowIntake,
+ yieldFlowRoutes,
} from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
import {
leaveYieldFlowExecution,
@@ -43,11 +42,14 @@ import {
InputValidationError,
} from "../../../src/services/api/resource-sources";
import {
- toWidgetPath,
type WidgetNavigation,
type WidgetNavigationCommand,
WidgetNavigationError,
} from "../../../src/services/navigation/widget-navigation";
+import {
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model";
import {
type TransactionWorkflowInput,
@@ -73,6 +75,7 @@ const address = Schema.decodeSync(WalletAddress)(
"0x1234567890123456789012345678901234567890"
);
const walletScope = new WalletScopeKey({ address, network: "ethereum" });
+const integrationId = Schema.decodeSync(YieldId)("integration");
const otherWalletScope = new WalletScopeKey({
address: Schema.decodeSync(WalletAddress)(
"0x2234567890123456789012345678901234567890"
@@ -173,7 +176,7 @@ const exitStart = (): NavigatingYieldTransactionFlowStart => ({
mount: {
_tag: "PositionExit",
balanceId: "balance",
- integrationId: "integration",
+ integrationId,
},
});
@@ -210,7 +213,12 @@ const makeKit = (overrides: KitOverrides = {}) =>
// compare routes only.
const withoutNavigationState = (
commands: ReadonlyArray
-) => commands.map(({ state: _state, ...command }) => command);
+) =>
+ commands.map((command) =>
+ command._tag === "Back"
+ ? command
+ : { ...command, to: widgetTargetPath(command.to) }
+ );
const unavailablePreview = () =>
Effect.fail(
@@ -258,29 +266,31 @@ describe("makeStartYieldTransactionFlow", () => {
expect(second.session.intake.walletScope).not.toBe(
input.intake.walletScope
);
- expect(second.session.destination).toEqual({
- completePath: toWidgetPath("/complete"),
- finishPath: toWidgetPath("/"),
- reviewPath: toWidgetPath("/review"),
- stepsPath: toWidgetPath("/steps"),
+ expect(second.session.destination).toMatchObject({
+ completePath: "/complete",
+ finishPath: "/",
+ reviewPath: "/review",
+ stepsPath: "/steps",
});
expect(
(yield* kit.navigation.commands).map((command) => ({
_tag: command._tag,
- path: command._tag === "Back" ? null : command.path,
+ path: command._tag === "Back" ? null : widgetTargetPath(command.to),
session: Option.getOrNull(
- decodeYieldFlowNavigationState(command.state)
+ yieldFlowRoutes.Earn.state(
+ command._tag === "Back" ? null : widgetTargetState(command.to)
+ ).pipe(Option.map((state) => state.yieldFlowSession))
),
}))
).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/review"),
+ path: "/review",
session: first.session,
},
{
_tag: "Push",
- path: toWidgetPath("/review"),
+ path: "/review",
session: second.session,
},
]);
@@ -301,7 +311,7 @@ describe("makeStartYieldTransactionFlow", () => {
expect(error).toMatchObject({ _tag: "WidgetNavigationError" });
expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([
- { _tag: "Push", path: toWidgetPath("/review") },
+ { _tag: "Push", to: "/review" },
]);
})
);
@@ -540,13 +550,24 @@ describe("handOffYieldFlowSource", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/steps"),
- state: makeYieldFlowNavigationState(handle, handoff),
+ to: {
+ path: "/steps",
+ state: { executionHandoff: handoff, yieldFlowSession: handle },
+ },
},
]);
+ const [command] = yield* kit.navigation.commands;
expect(
- decodeYieldFlowExecutionHandoff(
- (yield* kit.navigation.commands)[0]?.state
+ Option.getOrNull(
+ yieldFlowRoutes.Earn.state(
+ command && command._tag !== "Back"
+ ? widgetTargetState(command.to)
+ : null
+ ).pipe(
+ Option.flatMap((state) =>
+ Option.fromUndefinedOr(state.executionHandoff)
+ )
+ )
)
).toBe(handoff);
expect(yield* kit.tracking.trackedEvents).toEqual([]);
@@ -579,8 +600,10 @@ describe("handOffYieldFlowSource", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/positions/integration/balance/unstake/steps"),
- state: makeYieldFlowNavigationState(handle, handoff),
+ to: {
+ path: "/positions/integration/balance/unstake/steps",
+ state: { executionHandoff: handoff, yieldFlowSession: handle },
+ },
},
]);
})
@@ -823,8 +846,10 @@ describe("startYieldFlowExecution", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath(`/activity/${action.id}/steps`),
- state: makeYieldFlowNavigationState(handle, handoff),
+ to: {
+ path: `/activity/${action.id}/steps`,
+ state: { executionHandoff: handoff, yieldFlowSession: handle },
+ },
},
]);
})
@@ -934,7 +959,7 @@ describe("leaveYieldFlowExecution", () => {
Effect.gen(function* () {
expect(
yield* leave(makeYieldFlowSession(enterStart(), walletScope), "Review")
- ).toEqual([{ _tag: "Replace", path: toWidgetPath("/review") }]);
+ ).toEqual([{ _tag: "Replace", to: "/review" }]);
})
);
@@ -942,7 +967,7 @@ describe("leaveYieldFlowExecution", () => {
Effect.gen(function* () {
expect(
yield* leave(makeYieldFlowSession(enterStart(), walletScope), "Exit")
- ).toEqual([{ _tag: "Push", path: toWidgetPath("/") }]);
+ ).toEqual([{ _tag: "Push", to: "/" }]);
})
);
@@ -955,7 +980,7 @@ describe("leaveYieldFlowExecution", () => {
mount: {
_tag: "PositionManage",
balanceId: "balance",
- integrationId: "integration",
+ integrationId,
},
}),
],
@@ -966,7 +991,7 @@ describe("leaveYieldFlowExecution", () => {
mount: {
_tag: "PositionStake",
balanceId: "balance",
- integrationId: "integration",
+ integrationId,
},
}),
],
@@ -974,7 +999,7 @@ describe("leaveYieldFlowExecution", () => {
Effect.gen(function* () {
expect(
yield* leave(makeYieldFlowSession(start(), walletScope), "Exit")
- ).toEqual([{ _tag: "Push", path: toWidgetPath("/positions") }]);
+ ).toEqual([{ _tag: "Push", to: "/positions" }]);
})
);
@@ -985,7 +1010,7 @@ describe("leaveYieldFlowExecution", () => {
makeYieldActionContinuationSession(makeContinuationIntake()),
"Exit"
)
- ).toEqual([{ _tag: "Push", path: toWidgetPath("/activity") }]);
+ ).toEqual([{ _tag: "Push", to: "/activity" }]);
})
);
@@ -1018,7 +1043,7 @@ describe("leaveYieldFlowExecution", () => {
expect(interruptedWhileBackPending).toBeUndefined();
expect(yield* kit.navigation.commands).toEqual([
- { _tag: "Replace", path: toWidgetPath("/review") },
+ { _tag: "Replace", to: "/review" },
]);
})
);
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts
index b579f0aba..b245d6989 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts
@@ -6,11 +6,13 @@ import * as Reactivity from "effect/reactivity/Reactivity";
import { appRuntime } from "../../../src/app/runtime/app-runtime";
import { walletRuntime } from "../../../src/app/runtime/wallet-runtime";
import { TokenBalance } from "../../../src/domain/finance/models";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import {
makeYieldActionContinuationSession,
- makeYieldFlowNavigationState,
makeYieldFlowSession,
YieldFlowHandoff,
type YieldFlowSession,
@@ -28,7 +30,6 @@ import {
LegacyResourceSource,
YieldResourceSource,
} from "../../../src/services/api/resource-sources";
-import { toWidgetPath } from "../../../src/services/navigation/widget-navigation";
import { resourceInvalidationKeys } from "../../../src/services/resource-invalidation";
import {
yieldApiActionFixture,
@@ -42,6 +43,7 @@ const address = Schema.decodeSync(WalletAddress)(
"0x1234567890123456789012345678901234567890"
);
const walletScope = new WalletScopeKey({ address, network: "ethereum" });
+const integrationId = Schema.decodeSync(YieldId)("integration");
const selectedStake = yieldApiYieldFixture();
const gasToken = selectedStake.mechanics.gasFeeToken;
const enterIntake: YieldTransactionFlowIntake = {
@@ -254,7 +256,7 @@ describe("Yield flow Review page", () => {
mount: {
_tag: "PositionExit",
balanceId: "balance",
- integrationId: "integration",
+ integrationId,
},
},
walletScope
@@ -312,8 +314,10 @@ describe("Yield flow Review page", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath("/steps"),
- state: makeYieldFlowNavigationState(handle, handoff),
+ to: {
+ path: "/steps",
+ state: { executionHandoff: handoff, yieldFlowSession: handle },
+ },
},
]);
@@ -469,8 +473,13 @@ describe("Yield flow Review page", () => {
expect(yield* kit.navigation.commands).toEqual([
{
_tag: "Push",
- path: toWidgetPath(`/activity/${action.id}/steps`),
- state: makeYieldFlowNavigationState(handle, held?.handoff),
+ to: {
+ path: `/activity/${action.id}/steps`,
+ state: {
+ executionHandoff: held?.handoff,
+ yieldFlowSession: handle,
+ },
+ },
},
]);
})
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx
index 125a31a8f..b3f55bd95 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx
@@ -11,11 +11,13 @@ import {
useNavigate,
} from "react-router";
import { describe, expect, it } from "vitest";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import { walletScopeAtom } from "../../../src/features/wallet/index";
import {
- makeYieldFlowNavigationState,
makeYieldFlowSession,
type YieldFlowSession,
} from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
@@ -80,7 +82,7 @@ const makeExitSession = () => {
mount: {
_tag: "PositionExit",
balanceId: "balance",
- integrationId: "yield",
+ integrationId: Schema.decodeSync(YieldId)("yield"),
},
},
walletScope
@@ -140,7 +142,7 @@ const renderFlow = async (
}}
/>
- }>
+ }>
}
@@ -178,7 +180,7 @@ describe("Yield Flow route", () => {
it("redirects home when the navigated Session has another intake variant", async () => {
const flow = await renderFlow({
pathname: "/review",
- state: makeYieldFlowNavigationState(makeExitSession()),
+ state: { yieldFlowSession: makeExitSession() },
});
await expect.poll(flow.location).toBe("/");
@@ -189,7 +191,7 @@ describe("Yield Flow route", () => {
const flow = await renderFlow(
{
pathname: "/review",
- state: makeYieldFlowNavigationState(makeEnterSession()),
+ state: { yieldFlowSession: makeEnterSession() },
},
otherWalletScope
);
@@ -202,7 +204,7 @@ describe("Yield Flow route", () => {
const session = makeEnterSession();
const flow = await renderFlow({
pathname: "/review",
- state: makeYieldFlowNavigationState(session),
+ state: { yieldFlowSession: session },
});
await expect.poll(flow.page).toBe("Review:1");
@@ -227,11 +229,11 @@ describe("Yield Flow route", () => {
const second = makeEnterSession();
const flow = await renderFlow({
pathname: "/review",
- state: makeYieldFlowNavigationState(first),
+ state: { yieldFlowSession: first },
});
await expect.poll(flow.page).toBe("Review:1");
- await flow.navigate("/review", makeYieldFlowNavigationState(second));
+ await flow.navigate("/review", { yieldFlowSession: second });
await expect.poll(flow.page).toBe("Review:2");
await flow.navigate("/steps");
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx
index 21dde5821..749566019 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx
@@ -2,6 +2,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
import { createRoutesFromElements, type RouteObject } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { createYieldFlowRoutes } from "../../../src/features/yield-transaction-flow/composition";
+import { widgetYieldFlowRoutes } from "../../../src/services/navigation/widget-routes";
vi.mock(
"../../../src/features/yield-transaction-flow/react/yield-flow-route",
@@ -133,11 +134,13 @@ const getRouteContract = (
const standardRouteContract = ({
completePage,
journey,
+ mount,
reviewPage,
stepsPage,
}: {
readonly completePage: string;
readonly journey: "Enter" | "Exit" | "Manage";
+ readonly mount: "Earn" | "PositionExit" | "PositionManage" | "PositionStake";
readonly reviewPage: string;
readonly stepsPage: string;
}): ReadonlyArray => [
@@ -147,7 +150,7 @@ const standardRouteContract = ({
children: [],
element: reviewPage,
index: false,
- path: "review",
+ path: widgetYieldFlowRoutes[mount].review.pattern,
},
{
children: [
@@ -155,13 +158,13 @@ const standardRouteContract = ({
children: [],
element: `YieldFlowExecutionPage>${stepsPage}`,
index: false,
- path: "steps",
+ path: widgetYieldFlowRoutes[mount].steps.pattern,
},
{
children: [],
element: `YieldFlowExecutionPage>${completePage}`,
index: false,
- path: "complete",
+ path: widgetYieldFlowRoutes[mount].complete.pattern,
},
],
element: "YieldFlowExecutionRoute",
@@ -178,6 +181,7 @@ const standardRouteContract = ({
describe("Yield Transaction Flow routes", () => {
it.each([
[
+ "Earn",
"Enter",
{
completePage: "StakeCompletePage",
@@ -186,6 +190,16 @@ describe("Yield Transaction Flow routes", () => {
},
],
[
+ "PositionStake",
+ "Enter",
+ {
+ completePage: "StakeCompletePage",
+ reviewPage: "StakeReviewPage",
+ stepsPage: "StakeStepsPage",
+ },
+ ],
+ [
+ "PositionExit",
"Exit",
{
completePage: "UnstakeCompletePage",
@@ -194,6 +208,7 @@ describe("Yield Transaction Flow routes", () => {
},
],
[
+ "PositionManage",
"Manage",
{
completePage: "PendingCompletePage",
@@ -201,9 +216,12 @@ describe("Yield Transaction Flow routes", () => {
stepsPage: "PendingStepsPage",
},
],
- ] as const)("publishes the %s journey", (journey, pages) => {
- expect(getRouteContract(createYieldFlowRoutes({ journey }))).toEqual(
- standardRouteContract({ journey, ...pages })
- );
- });
+ ] as const)(
+ "publishes the %s mount's %s journey",
+ (mount, journey, pages) => {
+ expect(getRouteContract(createYieldFlowRoutes({ mount }))).toEqual(
+ standardRouteContract({ journey, mount, ...pages })
+ );
+ }
+ );
});
diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts
index 375ef8384..0186f49a1 100644
--- a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts
+++ b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts
@@ -6,7 +6,10 @@ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
import { describe, expect, it, vi } from "vitest";
import { appRuntime } from "../../../src/app/runtime/app-runtime";
import { walletRuntime } from "../../../src/app/runtime/wallet-runtime";
-import { WalletAddress } from "../../../src/domain/identity/identifiers";
+import {
+ WalletAddress,
+ YieldId,
+} from "../../../src/domain/identity/identifiers";
import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope";
import { walletScopeAtom } from "../../../src/features/wallet/index";
import {
@@ -14,15 +17,16 @@ import {
startYieldTransactionFlowAtom,
} from "../../../src/features/yield-transaction-flow/index";
import {
- decodeYieldFlowNavigationState,
type NavigatingYieldTransactionFlowStart,
type YieldTransactionFlowIntake,
+ yieldFlowRoutes,
} from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow";
+import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation";
import {
- makeWidgetNavigation,
- type WidgetNavigationOptions,
- type WidgetPath,
-} from "../../../src/services/navigation/widget-navigation";
+ type WidgetNavigationTarget,
+ widgetTargetPath,
+ widgetTargetState,
+} from "../../../src/services/navigation/widget-routes";
import {
disconnectedLedgerConnectorState,
disconnectedNormalizedWalletState,
@@ -111,12 +115,12 @@ const makeManageIntake = (): Intake<"Manage"> => {
};
const makeRegistry = (
- push: (path: WidgetPath, options?: WidgetNavigationOptions) => void,
+ push: (to: WidgetNavigationTarget) => void,
currentWalletScope: WalletScopeKey = walletScope
) => {
const navigation = makeWidgetNavigation({
back: () => Effect.void,
- push: (path, options) => Effect.sync(() => push(path, options)),
+ push: (to) => Effect.sync(() => push(to)),
replace: () => Effect.void,
});
const walletState = {
@@ -190,7 +194,7 @@ const startCases: ReadonlyArray<
mount: {
_tag: "PositionStake",
balanceId: "balance",
- integrationId: "yield",
+ integrationId: Schema.decodeSync(YieldId)("yield"),
},
}),
name: "position Stake",
@@ -205,7 +209,7 @@ const startCases: ReadonlyArray<
mount: {
_tag: "PositionExit",
balanceId: "balance",
- integrationId: "yield",
+ integrationId: Schema.decodeSync(YieldId)("yield"),
},
}),
name: "position Exit",
@@ -220,7 +224,7 @@ const startCases: ReadonlyArray<
mount: {
_tag: "PositionManage",
balanceId: "balance",
- integrationId: "yield",
+ integrationId: Schema.decodeSync(YieldId)("yield"),
},
}),
name: "position Manage",
@@ -258,8 +262,7 @@ describe("Yield Transaction Flow interface", () => {
reviewPath,
stepsPath,
}) => {
- const push =
- vi.fn<(path: WidgetPath, options?: WidgetNavigationOptions) => void>();
+ const push = vi.fn<(to: WidgetNavigationTarget) => void>();
const registry = makeRegistry(push);
const command = makeCommand();
@@ -270,14 +273,17 @@ describe("Yield Transaction Flow interface", () => {
.poll(() => readStartOutcome(registry)?._tag)
.toBe("Started");
expect(push).toHaveBeenCalledOnce();
- const [path, options] = push.mock.calls[0] ?? [];
- expect(path).toBe(reviewPath);
+ const [to] = push.mock.calls[0] ?? [];
+ if (to === undefined) throw new Error("Expected a Review navigation");
+ expect(widgetTargetPath(to)).toBe(reviewPath);
const session = Option.getOrThrow(
- decodeYieldFlowNavigationState(options?.state)
+ yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe(
+ Option.map((state) => state.yieldFlowSession)
+ )
);
expect(session.intake).toEqual(command.intake);
expect(session.mount).toEqual(command.mount);
- expect(session.destination).toEqual({
+ expect(session.destination).toMatchObject({
completePath,
finishPath,
reviewPath,
@@ -325,16 +331,21 @@ describe("Yield Flow route group", () => {
["/activity/action-1/steps", "/activity/action-1"],
["/activity/action-1/complete", "/activity/action-1"],
["/activity/action-2/steps", "/activity/action-2"],
- ["/", null],
- ["/positions", null],
- ["/positions/yield/balance", null],
- ["/activity", null],
- ["/activity/", null],
- ["/borrow/review", null],
] as const)("groups %s as %s", (pathname, group) => {
expect(getYieldFlowRouteGroup(pathname)).toBe(group);
});
+ it.each([
+ "/",
+ "/positions",
+ "/positions/yield/balance",
+ "/activity",
+ "/activity/",
+ "/borrow/review",
+ ])("does not group %s", (pathname) => {
+ expect(getYieldFlowRouteGroup(pathname)).toBeNull();
+ });
+
it("shares a group across one mount's flow routes and separates other mounts", () => {
const groupsOf = (paths: ReadonlyArray) =>
new Set(paths.map(getYieldFlowRouteGroup));
@@ -344,6 +355,7 @@ describe("Yield Flow route group", () => {
"/positions/yield/balance/unstake",
"/positions/yield/balance/pending-action",
"/positions/yield/other/stake",
+ "/positions/yield/other/pending-action",
];
for (const base of mounts) {
@@ -354,5 +366,8 @@ describe("Yield Flow route group", () => {
expect(groupsOf(mounts.map((base) => `${base}/review`)).size).toBe(
mounts.length
);
+ expect(
+ groupsOf(["/activity/action-1/steps", "/activity/action-2/steps"]).size
+ ).toBe(2);
});
});
diff --git a/packages/widget/tests/services/navigation/widget-navigation.test.ts b/packages/widget/tests/services/navigation/widget-navigation.test.ts
index 4b38c141a..efb14157e 100644
--- a/packages/widget/tests/services/navigation/widget-navigation.test.ts
+++ b/packages/widget/tests/services/navigation/widget-navigation.test.ts
@@ -1,12 +1,9 @@
import { describe, expect, it, vi } from "@effect/vitest";
-import { Effect, Layer, Schema, Stream } from "effect";
+import { Effect, Layer, Stream } from "effect";
import type { DataRouter } from "react-router";
import { ApplicationRouter } from "../../../src/services/navigation/application-router";
-import {
- toWidgetPath,
- WidgetNavigation,
- WidgetPath,
-} from "../../../src/services/navigation/widget-navigation";
+import { WidgetNavigation } from "../../../src/services/navigation/widget-navigation";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
describe("WidgetNavigation", () => {
it.effect("executes absolute push, replace, back, and scroll decisions", () =>
@@ -20,10 +17,10 @@ describe("WidgetNavigation", () => {
const { router } = yield* ApplicationRouter;
const navigation = yield* WidgetNavigation;
- yield* navigation.push(toWidgetPath("/positions/one"), {
+ yield* navigation.push(widgetRoutes.positions.build(), {
scroll: "reset",
});
- yield* navigation.replace(toWidgetPath("/positions/two"), {
+ yield* navigation.replace(widgetRoutes.activity.build(), {
scroll: "preserve",
});
yield* navigation.back({ scroll: "reset" });
@@ -51,18 +48,12 @@ describe("WidgetNavigation", () => {
})
);
- it("rejects route-relative application paths", () => {
- expect(() => Schema.decodeUnknownSync(WidgetPath)("positions/one")).toThrow(
- "Expected a string matching template literal parts"
- );
- });
-
it.effect("normalizes router failures", () =>
Effect.gen(function* () {
const applicationRouterLayer = Layer.succeed(
ApplicationRouter,
ApplicationRouter.of({
- pathnames: Stream.never,
+ locations: Stream.never,
router: {
navigate: () =>
Promise.reject(new Error("router rejected navigation")),
@@ -70,7 +61,7 @@ describe("WidgetNavigation", () => {
})
);
const program = WidgetNavigation.use((navigation) =>
- navigation.push(toWidgetPath("/review"), { scroll: "preserve" })
+ navigation.push(widgetRoutes.earn.build(), { scroll: "preserve" })
).pipe(
Effect.provide(
WidgetNavigation.layer().pipe(Layer.provide(applicationRouterLayer))
diff --git a/packages/widget/tests/services/navigation/widget-routes.test.ts b/packages/widget/tests/services/navigation/widget-routes.test.ts
new file mode 100644
index 000000000..facc593c7
--- /dev/null
+++ b/packages/widget/tests/services/navigation/widget-routes.test.ts
@@ -0,0 +1,69 @@
+import { Option, Schema } from "effect";
+import { describe, expect, it } from "vitest";
+import { YieldId } from "../../../src/domain/identity/identifiers";
+import {
+ isWidgetPath,
+ widgetRoutes,
+ widgetYieldFlowRoutes,
+} from "../../../src/services/navigation/widget-routes";
+
+describe("widget route registry", () => {
+ it("round-trips params that need encoding", () => {
+ const params = {
+ balanceId: "a/b c?%",
+ integrationId: Schema.decodeSync(YieldId)("eth-lido#1"),
+ };
+ const path = widgetRoutes.positionDetails.build(params);
+
+ expect(path).toBe("/positions/eth-lido%231/a%2Fb%20c%3F%25");
+ expect(widgetRoutes.positionDetails.match(path)).toEqual(
+ Option.some(params)
+ );
+ });
+
+ it("does not match malformed encodings or branded ids that fail decoding", () => {
+ expect(
+ Option.isNone(widgetRoutes.positionDetails.match("/positions/%E0/b"))
+ ).toBe(true);
+ expect(
+ Option.isNone(
+ widgetRoutes.activityAction.match(`/activity/${encodeURIComponent("")}`)
+ )
+ ).toBe(true);
+ });
+
+ it("matches exactly, tolerating a trailing slash, or as a section prefix", () => {
+ const route = widgetRoutes.positions;
+
+ expect(Option.isSome(route.match("/positions/"))).toBe(true);
+ expect(Option.isNone(route.match("/positions/yield-1/balance-1"))).toBe(
+ true
+ );
+ expect(
+ Option.isSome(route.matchPrefix("/positions/yield-1/balance-1"))
+ ).toBe(true);
+ expect(Option.isNone(route.matchPrefix("/positionsx"))).toBe(true);
+ expect(isWidgetPath("/positions/", route.build())).toBe(true);
+ });
+
+ it("keys a flow mount's Review, Steps, and Complete alike", () => {
+ const flow = widgetYieldFlowRoutes.PositionExit;
+ const params = {
+ balanceId: "balance-1",
+ integrationId: Schema.decodeSync(YieldId)("yield-1"),
+ };
+ const { completePath, reviewPath, stepsPath } = flow.at(params);
+ const other = flow.at({ ...params, balanceId: "balance-2" });
+
+ const key = flow.mountKey(reviewPath);
+ expect(key).toEqual(
+ Option.some(widgetRoutes.positionUnstake.build(params))
+ );
+ expect(flow.mountKey(stepsPath)).toEqual(key);
+ expect(flow.mountKey(completePath)).toEqual(key);
+ expect(flow.mountKey(other.reviewPath)).not.toEqual(key);
+ expect(
+ Option.isNone(flow.mountKey(widgetRoutes.positionUnstake.build(params)))
+ ).toBe(true);
+ });
+});
diff --git a/packages/widget/tests/utils/services/widget-navigation.test.ts b/packages/widget/tests/utils/services/widget-navigation.test.ts
index b8d387392..159acd1b7 100644
--- a/packages/widget/tests/utils/services/widget-navigation.test.ts
+++ b/packages/widget/tests/utils/services/widget-navigation.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Ref } from "effect";
import {
- toWidgetPath,
type WidgetNavigationCommand,
WidgetNavigationError,
} from "../../../src/services/navigation/widget-navigation";
+import { widgetRoutes } from "../../../src/services/navigation/widget-routes";
import { makeTestNavigation } from "./widget-navigation";
describe("makeTestNavigation", () => {
@@ -14,16 +14,16 @@ describe("makeTestNavigation", () => {
Effect.gen(function* () {
const navigation = yield* makeTestNavigation();
- yield* navigation.service.push(toWidgetPath("/earn"));
- yield* navigation.service.replace(toWidgetPath("/activity"), {
+ yield* navigation.service.push(widgetRoutes.earn.build());
+ yield* navigation.service.replace(widgetRoutes.activity.build(), {
scroll: "preserve",
});
- yield* navigation.service.back({ state: { source: "test" } });
+ yield* navigation.service.back({ scroll: "preserve" });
expect(yield* navigation.commands).toEqual([
- { _tag: "Push", path: "/earn" },
- { _tag: "Replace", path: "/activity", scroll: "preserve" },
- { _tag: "Back", state: { source: "test" } },
+ { _tag: "Push", to: "/" },
+ { _tag: "Replace", scroll: "preserve", to: "/activity" },
+ { _tag: "Back", scroll: "preserve" },
]);
yield* navigation.clear;
@@ -41,13 +41,13 @@ describe("makeTestNavigation", () => {
});
const error = yield* Effect.flip(
- navigation.service.push(toWidgetPath("/earn"))
+ navigation.service.push(widgetRoutes.earn.build())
);
expect(error).toBe(failure);
expect(yield* Ref.get(received)).toEqual({
_tag: "Push",
- path: "/earn",
+ to: "/",
});
})
);
diff --git a/packages/widget/tests/utils/services/widget-navigation.ts b/packages/widget/tests/utils/services/widget-navigation.ts
index 7c70564d9..a60afe153 100644
--- a/packages/widget/tests/utils/services/widget-navigation.ts
+++ b/packages/widget/tests/utils/services/widget-navigation.ts
@@ -24,10 +24,10 @@ export const makeTestNavigation = Effect.fn("makeTestNavigation")(function* (
const service = makeWidgetNavigation({
back: (navigationOptions) =>
execute({ ...navigationOptions, _tag: "Back" }),
- push: (path, navigationOptions) =>
- execute({ ...navigationOptions, _tag: "Push", path }),
- replace: (path, navigationOptions) =>
- execute({ ...navigationOptions, _tag: "Replace", path }),
+ push: (to, navigationOptions) =>
+ execute({ ...navigationOptions, _tag: "Push", to }),
+ replace: (to, navigationOptions) =>
+ execute({ ...navigationOptions, _tag: "Replace", to }),
});
return {
diff --git a/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml b/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml
new file mode 100644
index 000000000..303af7a5a
--- /dev/null
+++ b/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml
@@ -0,0 +1,34 @@
+id: no-raw-widget-path
+valid:
+ - 'navigate(widgetRoutes.activity.build())'
+ - 'navigation.push(widgetRoutes.positions.build())'
+ - 'router.navigate(-1)'
+ - 'const match = useWidgetRouteMatch(widgetRoutes.positionDetails)'
+ - 'const element = '
+ - 'const element = '
+ - 'const element = '
+ - 'const element = '
+ - 'const element = } />'
+ - 'const element = } />'
+ - 'const element = } />'
+ - 'const element = '
+ - 'const hex = value.startsWith("0x")'
+ - 'const scoped = account.startsWith(`${chainId}:`)'
+ - 'const root = segment === "/"'
+ - 'const endpoint = HttpClientRequest.get("/v1/yields")'
+invalid:
+ - 'navigate("/activity")'
+ - 'navigate(`action/${action.id}`)'
+ - 'router.navigate("/positions", { replace: true })'
+ - 'const match = useMatch("/activity/:actionId/steps")'
+ - 'const match = matchPath("positions/:integrationId/:balanceId", pathname)'
+ - 'const element = '
+ - 'const element = '
+ - 'const element = Positions'
+ - 'const element = Market'
+ - 'const element = } />'
+ - 'const element = {children}'
+ - 'const activity = location.pathname.startsWith("/activity")'
+ - 'const borrow = Match.when(startsWith("/borrow"), () => "borrow")'
+ - 'const earn = location.pathname === "/"'
+ - 'const other = "/positions" !== pathname'
diff --git a/tools/ast-grep/rules/no-raw-widget-path.yml b/tools/ast-grep/rules/no-raw-widget-path.yml
new file mode 100644
index 000000000..19a40d228
--- /dev/null
+++ b/tools/ast-grep/rules/no-raw-widget-path.yml
@@ -0,0 +1,123 @@
+id: no-raw-widget-path
+language: Tsx
+severity: error
+message: Build Widget paths, route patterns, and route matchers from the route registry (`services/navigation/widget-routes.ts`) instead of string literals. Only relative `..` redirects and `*` catch-alls may stay literal.
+files:
+ - packages/widget/src/**/*.ts
+ - packages/widget/src/**/*.tsx
+ignores:
+ - packages/widget/src/generated/**
+ - packages/widget/src/services/navigation/widget-routes.ts
+utils:
+ raw-path:
+ any:
+ - kind: template_string
+ - kind: string
+ not:
+ regex: ^["'](?:\.\.(?:/\.\.)*|\*)["']$
+ absolute-path-literal:
+ any:
+ - kind: template_string
+ regex: ^`/
+ - kind: string
+ regex: ^["']/
+ raw-path-value:
+ any:
+ - matches: raw-path
+ - kind: jsx_expression
+ has:
+ matches: raw-path
+ first-argument-raw-path:
+ kind: arguments
+ has:
+ nthChild: 1
+ matches: raw-path
+ router-element:
+ any:
+ - kind: jsx_opening_element
+ - kind: jsx_self_closing_element
+rule:
+ any:
+ # navigate("/…"), router.navigate("/…")
+ - kind: call_expression
+ all:
+ - has:
+ field: function
+ any:
+ - kind: identifier
+ regex: ^navigate$
+ - kind: member_expression
+ has:
+ field: property
+ regex: ^navigate$
+ - has:
+ field: arguments
+ matches: first-argument-raw-path
+ # useMatch("…"), matchPath("…")
+ - kind: call_expression
+ all:
+ - has:
+ field: function
+ regex: ^(?:useMatch|matchPath)$
+ - has:
+ field: arguments
+ matches: first-argument-raw-path
+ # ,
+ - kind: jsx_attribute
+ all:
+ - has:
+ kind: property_identifier
+ regex: ^to$
+ - has:
+ matches: raw-path-value
+ - inside:
+ matches: router-element
+ has:
+ field: name
+ regex: ^(?:Navigate|Link|NavLink|SKLink|WidgetNavigate|WidgetLink)$
+ #
+ - kind: jsx_attribute
+ all:
+ - has:
+ kind: property_identifier
+ regex: ^path$
+ - has:
+ matches: raw-path-value
+ - inside:
+ matches: router-element
+ has:
+ field: name
+ regex: ^Route$
+ # pathname.startsWith("/…"), startsWith("/…")
+ - kind: call_expression
+ all:
+ - has:
+ field: function
+ any:
+ - kind: identifier
+ regex: ^startsWith$
+ - kind: member_expression
+ has:
+ field: property
+ regex: ^startsWith$
+ - has:
+ field: arguments
+ has:
+ nthChild: 1
+ matches: absolute-path-literal
+ # pathname === "/…"
+ - kind: binary_expression
+ all:
+ - has:
+ field: operator
+ regex: ^[!=]==?$
+ - has:
+ matches: absolute-path-literal
+ - has:
+ any:
+ - kind: identifier
+ regex: "[Pp]athname$"
+ - kind: member_expression
+ has:
+ field: property
+ regex: ^pathname$