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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<CustomerOrderSummary>
|
||||
}
|
||||
|
||||
// Must match the CSS transition duration on .order in
|
||||
// OrdersListView.module.scss.
|
||||
const EXIT_DURATION_MS = 280
|
||||
|
||||
const OrdersIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -32,10 +37,32 @@ const formatItems = (items: CustomerOrderSummary['items']) =>
|
||||
)
|
||||
.join(', ')
|
||||
|
||||
export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
||||
export const OrdersListView = ({ orders }: OrdersListViewProps) => {
|
||||
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 (
|
||||
<section className={styles.page}>
|
||||
<h1 className={styles.heading}>Your orders</h1>
|
||||
{orders.length === 0 ? (
|
||||
{displayedOrders.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<OrdersIcon />
|
||||
<p className={styles.emptyTitle}>No orders yet</p>
|
||||
@@ -48,8 +75,11 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.orders}>
|
||||
{orders.map((order) => (
|
||||
<li key={order.id}>
|
||||
{displayedOrders.map((order) => (
|
||||
<li
|
||||
key={order.id}
|
||||
className={isExiting ? styles.orderExiting : undefined}
|
||||
>
|
||||
<Link
|
||||
to="/orders/$orderId"
|
||||
params={{ orderId: String(order.id) }}
|
||||
@@ -65,7 +95,9 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
||||
<span className={styles.orderName}>Order #{order.id}</span>
|
||||
<span className={styles.orderStatus}>{order.status}</span>
|
||||
</div>
|
||||
<p className={styles.orderItems}>{formatItems(order.items)}</p>
|
||||
<p className={styles.orderItems}>
|
||||
{formatItems(order.items)}
|
||||
</p>
|
||||
<p className={styles.orderMeta}>
|
||||
{order.createdAt.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
@@ -80,3 +112,4 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -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<Array<CustomerOrderSummary> | null>(null)
|
||||
if (resolvedOrders !== null) lastKnownOrders.current = resolvedOrders
|
||||
const orders = resolvedOrders ?? lastKnownOrders.current
|
||||
if (orders === null) return null
|
||||
|
||||
return <OrdersListView orders={orders} />
|
||||
|
||||
Reference in New Issue
Block a user