From c4fccf83e35b62ec0068ae6abd7a359dcf984468 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Tue, 27 Oct 2026 18:15:01 +0100 Subject: [PATCH 1/2] refactor(widget): type routes through a registry Route paths were plain strings built, matched, and parsed in many places, so renaming a route broke callers silently. - Add the Route Registry (services/navigation/widget-routes.ts): one definition per route with typed params, build, match and matchPrefix, and the Layouts that mount it. Building a route some Layout does not mount requires naming the Layout. - Flow groups (Review, Steps, Complete per mount) carry the navigation state their owner binds with `withState`, replacing hand decoding. - WidgetNavigation accepts only registry-built paths; React adapters live in the navigation Feature. Pathname parsing uses registry matching. - Enforce with ast-grep (no raw widget paths) and a test that each Layout mounts exactly the routes declared for it. --- packages/widget/ARCHITECTURE.md | 22 +- .../providers/mount-animation/index.tsx | 9 +- .../app/layouts/compact/compact-routes.tsx | 152 +++---- .../layouts/dashboard/dashboard-routes.tsx | 192 +++++---- .../dashboard/dashboard-tabs/index.tsx | 49 ++- .../src/app/routes/model/initial-route.ts | 28 +- .../model/tab-resources-prefetch-gate.ts | 15 +- .../routes/state/tab-resources-prefetch.ts | 4 +- .../app/routes/ui/borrow-feature-route.tsx | 8 +- .../src/app/runtime/application-router.ts | 16 +- .../src/app/runtime/deep-link-coordinator.ts | 30 +- .../src/domain/action/pending-action.ts | 11 +- .../activity/model/activity-action.ts | 12 +- .../activity/react/activity-action-route.tsx | 35 +- .../activity/react/use-activity-page.ts | 18 +- .../widget/src/features/activity/routes.tsx | 32 +- .../activity-details.page.tsx | 7 +- .../activity/ui/dashboard/activity/index.tsx | 12 +- .../model/borrow-transaction-flow.ts | 83 ++-- .../react/borrow-flow-route.tsx | 88 ++-- .../state/borrow-flow-session.ts | 10 +- .../state/review-page.ts | 11 +- .../ui/borrow-flow-routes.tsx | 56 +-- .../borrow-transaction-flow/ui/steps.tsx | 4 +- .../borrow/borrow-entry/ui/routes.tsx | 47 ++- .../widget/src/features/borrow/composition.ts | 3 +- .../borrow/market-position/ui/action.page.tsx | 18 +- .../market-position/ui/actions.page.tsx | 20 +- .../ui/components/breadcrumb.tsx | 14 +- .../borrow/market-position/ui/context.ts | 3 - .../market-position/ui/details.page.tsx | 25 +- .../borrow/market-position/ui/routes.tsx | 46 +- .../wallet/ui/connected-wallet-route.tsx | 6 +- .../react/use-leave-confirmation.ts | 7 +- .../widget/src/features/navigation/index.ts | 6 + .../navigation/react/widget-navigation.ts | 46 ++ .../navigation/ui/widget-navigate.tsx | 35 ++ .../widget/src/features/navigation/views.ts | 1 + .../ui/components/earn-position-list-item.tsx | 13 +- .../components/positions-list-item.tsx | 11 +- .../src/features/position-details/index.ts | 1 - .../features/position-details/model/hub.ts | 8 - ...e-pending-action-select-validator-match.ts | 6 - .../react/use-position-details-hub-match.ts | 4 - .../react/use-position-details-stake-match.ts | 13 - .../use-unstake-or-pending-action-match.ts | 14 - .../use-unstake-or-pending-action-params.ts | 32 +- .../position-details/state/workflow.ts | 7 +- .../ui/compact/position-details.page.tsx | 5 +- .../hooks/use-position-details-stake.ts | 9 +- .../position-details/ui/dashboard/index.tsx | 18 +- .../wallet/react/wallet-scope-route.tsx | 10 +- .../widget-shell/react/use-details-match.ts | 14 +- .../features/widget-shell/react/use-header.ts | 6 +- .../ui/compact/details/components/tabs.tsx | 28 +- .../model/yield-transaction-flow.ts | 162 ++++--- .../react/yield-flow-route.tsx | 55 ++- .../state/execution-page.ts | 4 +- .../state/review-page.ts | 7 +- .../state/start-flow.ts | 4 +- .../ui/yield-flow-routes.tsx | 33 +- .../services/navigation/application-router.ts | 18 +- .../services/navigation/widget-navigation.ts | 44 +- .../src/services/navigation/widget-routes.ts | 396 ++++++++++++++++++ ...ct-position-details-redirects.dom.test.tsx | 25 +- .../tests/app/layouts/layout-routes.test.tsx | 104 +++++ .../routes/borrow-feature-route.dom.test.tsx | 21 +- .../app/routes/tab-resources-prefetch.test.ts | 4 +- .../app/runtime/application-router.test.ts | 25 +- .../pending-action-deep-link-route.test.ts | 54 ++- .../activity/activity-routes.test.tsx | 12 +- .../borrow-execution-flow.browser.test.tsx | 22 +- .../borrow-flow-operations.test.ts | 66 ++- .../borrow-flow-pages.test.ts | 14 +- .../borrow-flow-route-identity.dom.test.tsx | 14 +- .../borrow-flow-routes.test.tsx | 15 +- ...nsaction-flow-route-readiness.dom.test.tsx | 29 +- .../features/borrow/borrow-routes.test.tsx | 15 +- .../page-workflow-state.dom.test.tsx | 7 +- .../position-details-action-content.test.ts | 10 - .../position-details-exit-command.test.ts | 25 +- .../position-details-hub-model.test.ts | 10 - .../position-details-workflow.test.ts | 9 +- ...ition-details-yield-entry.browser.test.tsx | 25 +- .../wallet/wallet-scope-route.dom.test.tsx | 8 +- .../features/yield-entry/yield-entry.test.ts | 23 +- .../features/yield-flow-confidential.test.ts | 14 +- .../yield-flow-navigation.dom.test.tsx | 26 +- .../yield-flow-operations.test.ts | 87 ++-- .../yield-flow-review-page.test.ts | 25 +- .../yield-flow-route.dom.test.tsx | 20 +- .../yield-flow-routes.test.tsx | 34 +- .../yield-transaction-flow-interface.test.ts | 61 ++- .../navigation/widget-navigation.test.ts | 23 +- .../services/navigation/widget-routes.test.ts | 69 +++ .../utils/services/widget-navigation.test.ts | 18 +- .../tests/utils/services/widget-navigation.ts | 8 +- .../rule-tests/no-raw-widget-path-test.yml | 34 ++ tools/ast-grep/rules/no-raw-widget-path.yml | 123 ++++++ 99 files changed, 2189 insertions(+), 993 deletions(-) create mode 100644 packages/widget/src/features/navigation/index.ts create mode 100644 packages/widget/src/features/navigation/react/widget-navigation.ts create mode 100644 packages/widget/src/features/navigation/ui/widget-navigate.tsx create mode 100644 packages/widget/src/features/navigation/views.ts delete mode 100644 packages/widget/src/features/position-details/index.ts delete mode 100644 packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts delete mode 100644 packages/widget/src/features/position-details/react/use-position-details-hub-match.ts delete mode 100644 packages/widget/src/features/position-details/react/use-position-details-stake-match.ts delete mode 100644 packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts create mode 100644 packages/widget/src/services/navigation/widget-routes.ts create mode 100644 packages/widget/tests/app/layouts/layout-routes.test.tsx create mode 100644 packages/widget/tests/services/navigation/widget-routes.test.ts create mode 100644 tools/ast-grep/rule-tests/no-raw-widget-path-test.yml create mode 100644 tools/ast-grep/rules/no-raw-widget-path.yml diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md index 6506fef83..1a01ca9f1 100644 --- a/packages/widget/ARCHITECTURE.md +++ b/packages/widget/ARCHITECTURE.md @@ -43,6 +43,23 @@ 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). +## 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, +and the owning Feature binds the navigation state its pages carry to that +group. 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 +263,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/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/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-routes.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx index fa320519d..f0ece788f 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx @@ -1,3 +1,4 @@ +import { Option } from "effect"; import { AnimatePresence } from "motion/react"; import { useRef, useState } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router"; @@ -22,6 +23,7 @@ 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 { DashboardEarnFlowOverview, @@ -37,19 +39,119 @@ 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 = () => { @@ -93,85 +195,7 @@ export const DashboardRoutes = () => { {/* 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-tabs/index.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx index 42707ff03..cf96b3e6c 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx @@ -1,10 +1,14 @@ -import { Match } from "effect"; -import { startsWith } from "effect/String"; -import { useLocation, useNavigate } from "react-router"; +import { Option } from "effect"; +import { useLocation } from "react-router"; import type { YieldCategory } from "../../../../domain/earn/contract"; import { useEarnYieldSelection } from "../../../../features/earn/index"; +import { useWidgetNavigate } from "../../../../features/navigation/index"; import { useTrackEvent } from "../../../../features/tracking/index"; import { useWidgetConfig } from "../../../../features/widget-configuration/index"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../../services/navigation/widget-routes"; import { combineRecipeWithVariant } from "../../../../shared/styles/recipe-variant"; import { Divider } from "../../../../shared/ui/components/divider"; import { Box } from "../../../../shared/ui/primitives/box"; @@ -19,19 +23,24 @@ import { DashboardTab } from "./tab"; type RouteTab = "earn" | "borrow" | "manage" | "activity"; /** The route tab a dashboard pathname belongs to. */ -export const resolveDashboardRouteTab = (pathname: string): RouteTab => - 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 = ({ @@ -44,19 +53,17 @@ export const DashboardTabs = ({ 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) => { @@ -66,7 +73,7 @@ export const DashboardTabs = ({ trackEvent("tabClicked", { selected: category }); onYieldCategorySelect(category); - navigate("/"); + navigation.push(widgetRoutes.earn.build()); }; const borrowEnabled = useWidgetConfig("borrowEnabled"); 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/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/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..b03000249 --- /dev/null +++ b/packages/widget/src/services/navigation/widget-routes.ts @@ -0,0 +1,396 @@ +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; + } + + /** 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/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$ From 917fa4f3b74e6cd157d5addcb64f10140c43da0b Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 28 Oct 2026 10:26:44 +0100 Subject: [PATCH 2/2] fix(widget): preserve guarded navigation and layout ownership Carry category choices in typed route state so blocked navigation cannot remount Steps. Keep Stay on the current execution and apply accepted categories after the page exit. Move layout transition selection into application composition and cover category Stay/Leave with an animation-enabled browser regression. --- packages/widget/ARCHITECTURE.md | 12 +- packages/widget/src/App.tsx | 6 +- .../widget-configuration-unavailable.tsx | 10 +- .../layouts/dashboard/dashboard-earn-route.ts | 10 ++ .../layouts/dashboard/dashboard-routes.tsx | 55 +++++---- .../app/layouts/dashboard/dashboard-shell.tsx | 7 +- .../dashboard/dashboard-tabs/index.tsx | 7 +- .../src/app/layouts/layout-app-container.tsx | 62 +++++++++++ .../src/features/widget-shell/composition.ts | 1 + .../widget-shell/ui/animation-page.tsx | 30 +---- .../widget-shell/ui/app-container.tsx | 39 ++----- .../src/services/navigation/widget-routes.ts | 12 ++ .../leave-confirmation.browser.test.tsx | 105 ++++++++++++++++++ .../tests/app/journeys/staking-flow/setup.ts | 38 ++++++- 14 files changed, 292 insertions(+), 102 deletions(-) create mode 100644 packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts create mode 100644 packages/widget/src/app/layouts/layout-app-container.tsx diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md index 1a01ca9f1..c6229d356 100644 --- a/packages/widget/ARCHITECTURE.md +++ b/packages/widget/ARCHITECTURE.md @@ -43,6 +43,10 @@ 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 @@ -51,9 +55,11 @@ 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, -and the owning Feature binds the navigation state its pages carry to that -group. The `navigation` Feature adapts the registry to React. +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 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/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/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 f0ece788f..0521b9f73 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx @@ -1,6 +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 { @@ -14,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, @@ -25,6 +26,7 @@ import { GlobalModals } from "../../../features/widget-shell/composition"; 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, @@ -157,41 +159,52 @@ export const createDashboardRoutes = ({ 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. */} 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 })} > - + = { }; 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; }) => { @@ -72,8 +70,7 @@ export const DashboardTabs = ({ } trackEvent("tabClicked", { selected: category }); - onYieldCategorySelect(category); - navigation.push(widgetRoutes.earn.build()); + 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/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/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/services/navigation/widget-routes.ts b/packages/widget/src/services/navigation/widget-routes.ts index b03000249..f1a641ad4 100644 --- a/packages/widget/src/services/navigation/widget-routes.ts +++ b/packages/widget/src/services/navigation/widget-routes.ts @@ -149,6 +149,18 @@ class WidgetRoute< 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); 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 }) => {