From 5f375a09663e3cf0d16253e9fb2c694c4fc0d472 Mon Sep 17 00:00:00 2001 From: lukas Date: Sun, 13 Sep 2026 20:03:33 +0200 Subject: [PATCH] Animate orders out with a CSS transition instead of the View Transitions API The prior attempt wrapped the sign-out router.invalidate() in document.startViewTransition(), but that had two problems: invalidate() does its own network refetch inside the transition's update callback, which freezes the page for the round-trip instead of animating anything, and TanStack Router's invalidate() explicitly opts itself out of view transitions (sets shouldViewTransition = false) for exactly that reason. On top of that, OrdersRoute briefly returns null between the account loader clearing and the guest-orders lookup resolving, unmounting OrdersListView and wiping any exit-animation state anyway. Replace it with a plain, self-contained approach: - OrdersRoute now keeps rendering the last known non-null orders list through that brief gap (a lastKnownOrders ref) instead of unmounting OrdersListView, so its local animation state survives. - OrdersListView holds onto the previous non-empty list for EXIT_DURATION_MS after it goes empty, applying an `orderExiting` class that fades/slides each card out via a plain CSS transition, then swaps to the actual empty state once the transition finishes. This has no dependency on browser view-transition support, network timing, or router internals. Co-Authored-By: Claude Sonnet 5 --- .../orders/OrdersListView.module.scss | 10 +- src/components/orders/OrdersListView.tsx | 127 +++++++++++------- src/integrations/better-auth/header-user.tsx | 22 +-- src/routes/orders/index.tsx | 13 +- 4 files changed, 101 insertions(+), 71 deletions(-) diff --git a/src/components/orders/OrdersListView.module.scss b/src/components/orders/OrdersListView.module.scss index 8182558..de15d0c 100644 --- a/src/components/orders/OrdersListView.module.scss +++ b/src/components/orders/OrdersListView.module.scss @@ -71,13 +71,21 @@ background: $color-surface; color: inherit; text-decoration: none; - transition: border-color 0.15s ease; + transition: + border-color 0.15s ease, + opacity 0.28s ease, + transform 0.28s ease; &:hover { border-color: $color-primary; } } +.orderExiting .order { + opacity: 0; + transform: translateY(-8px) scale(0.98); +} + .orderHeader { display: flex; justify-content: space-between; diff --git a/src/components/orders/OrdersListView.tsx b/src/components/orders/OrdersListView.tsx index 95b8b54..f2db595 100644 --- a/src/components/orders/OrdersListView.tsx +++ b/src/components/orders/OrdersListView.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' import type { CustomerOrderSummary } from '#/domain/ordering.ts' import { formatPrice } from '#/lib/format.ts' @@ -9,6 +10,10 @@ type OrdersListViewProps = { orders: Array } +// Must match the CSS transition duration on .order in +// OrdersListView.module.scss. +const EXIT_DURATION_MS = 280 + const OrdersIcon = () => ( ) .join(', ') -export const OrdersListView = ({ orders }: OrdersListViewProps) => ( -
-

Your orders

- {orders.length === 0 ? ( -
- -

No orders yet

-

- Your placed orders will show up here. -

- - Browse the menu - -
- ) : ( -
    - {orders.map((order) => ( -
  • - { + const [displayedOrders, setDisplayedOrders] = useState(orders) + const [isExiting, setIsExiting] = useState(false) + const hadOrders = useRef(orders.length > 0) + + // Orders can go from populated to empty in one prop update (e.g. signing + // out clears them) — keep the previous list on screen just long enough to + // fade it out instead of popping straight to the empty state. + useEffect(() => { + if (orders.length === 0 && hadOrders.current) { + hadOrders.current = false + setIsExiting(true) + const timeout = setTimeout(() => { + setDisplayedOrders([]) + setIsExiting(false) + }, EXIT_DURATION_MS) + return () => clearTimeout(timeout) + } + hadOrders.current = orders.length > 0 + setDisplayedOrders(orders) + }, [orders]) + + return ( +
    +

    Your orders

    + {displayedOrders.length === 0 ? ( +
    + +

    No orders yet

    +

    + Your placed orders will show up here. +

    + + Browse the menu + +
    + ) : ( +
      + {displayedOrders.map((order) => ( +
    • -
      - Order #{order.id} - {order.status} -
      -

      {formatItems(order.items)}

      -

      - {order.createdAt.toLocaleDateString([], { - month: 'short', - day: 'numeric', - })}{' '} - · {formatPrice(order.totalCents)} -

      - -
    • - ))} -
    - )} -
    -) + +
    + Order #{order.id} + {order.status} +
    +

    + {formatItems(order.items)} +

    +

    + {order.createdAt.toLocaleDateString([], { + month: 'short', + day: 'numeric', + })}{' '} + · {formatPrice(order.totalCents)} +

    + +
  • + ))} +
+ )} + + ) +} diff --git a/src/integrations/better-auth/header-user.tsx b/src/integrations/better-auth/header-user.tsx index 1f1a359..cb267d3 100644 --- a/src/integrations/better-auth/header-user.tsx +++ b/src/integrations/better-auth/header-user.tsx @@ -153,27 +153,7 @@ const BetterAuthHeader = () => { clearBasket() void authClient.signOut({ fetchOptions: { - onSuccess: () => { - // Cross-fades the now-cleared Orders list out (and - // the empty state in) instead of popping instantly — - // each order card already carries its own - // `view-transition-name` (see OrdersListView), so - // this reuses that for a proper per-card exit. - if ('startViewTransition' in document) { - // Rejects if the browser can't actually run the - // transition right now (e.g. the tab isn't - // visible) — router.invalidate() still runs either - // way, so there's nothing to handle beyond not - // leaving this as an unhandled rejection. - const transition = document.startViewTransition(() => - router.invalidate(), - ) - transition.ready.catch(() => {}) - transition.finished.catch(() => {}) - } else { - void router.invalidate() - } - }, + onSuccess: () => void router.invalidate(), }, }) }} diff --git a/src/routes/orders/index.tsx b/src/routes/orders/index.tsx index 3d9d2bf..303ad97 100644 --- a/src/routes/orders/index.tsx +++ b/src/routes/orders/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { OrdersListView } from '#/components/orders/OrdersListView.tsx' import type { CustomerOrderSummary } from '#/domain/ordering.ts' @@ -25,7 +25,16 @@ const OrdersRoute = () => { void getGuestOrders({ data: { refs } }).then(setGuestOrders) }, [accountOrders]) - const orders = accountOrders ?? guestOrders + const resolvedOrders = accountOrders ?? guestOrders + + // Signing out clears both sources for a moment (the account loader + // reruns and comes back null, guest refs were just wiped) before the + // guest lookup above resolves to `[]` — keep showing the last known + // list through that gap instead of unmounting OrdersListView, so its + // own exit animation (see OrdersListView) can actually play. + const lastKnownOrders = useRef | null>(null) + if (resolvedOrders !== null) lastKnownOrders.current = resolvedOrders + const orders = resolvedOrders ?? lastKnownOrders.current if (orders === null) return null return