shared page: fix expand freeze — cache date formatter, memoize rows
ci / lint-test (push) Successful in 1m0s
ci / lint-test (push) Successful in 1m0s
Expanding a receipt on /shared froze the browser: this table renders every split row at once (1,279 today), and one state change re-rendered all of them. 520ms of each pass was formatDate constructing a fresh Intl.DateTimeFormat per call (2,558 calls per render); the rest was rebuilding 1,279 rows to change one. Headless-measured click stall: 802ms -> 165ms on the server; slower machines multiply the former. - module-level DATE_FMT, reused - rows extracted into memoized SharedTxRow with stable callbacks - filter+sort wrapped in useMemo so unrelated renders skip it
This commit is contained in:
+148
-104
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
@@ -20,8 +20,12 @@ import { OrderDetails } from "@/components/order-details";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS } from "@/lib/category-colors";
|
||||
|
||||
// One formatter, reused. `toLocaleDateString` constructs a fresh
|
||||
// Intl.DateTimeFormat per call, and this page renders every split row at once —
|
||||
// 2,558 calls per render was 520ms of the expand-click freeze on its own.
|
||||
const DATE_FMT = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
return DATE_FMT.format(new Date(d));
|
||||
}
|
||||
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
@@ -306,6 +310,125 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
||||
);
|
||||
}
|
||||
|
||||
// ── Split transaction row ─────────────────────────────────────────────────────
|
||||
// Memoized: this table renders every split row at once (1,279 today, no
|
||||
// pagination), so any state change on the page — expanding a receipt, each of
|
||||
// its two query results arriving — re-rendered all of them, ~800ms per pass on
|
||||
// a fast machine and a multi-second browser freeze on a slow one. With memo,
|
||||
// only the row whose `isExpanded` flipped re-renders. Every prop must stay
|
||||
// referentially stable: `tx` objects come straight out of the query cache, and
|
||||
// the callbacks are a state setter and a useCallback.
|
||||
const SharedTxRow = memo(function SharedTxRow({
|
||||
tx,
|
||||
isExpanded,
|
||||
meId,
|
||||
onToggle,
|
||||
onEdit,
|
||||
}: {
|
||||
tx: SharedTransactionRow;
|
||||
isExpanded: boolean;
|
||||
meId: number | undefined;
|
||||
onToggle: (id: number) => void;
|
||||
onEdit: (tx: SharedTransactionRow) => void;
|
||||
}) {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
return (
|
||||
<Fragment>
|
||||
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{tx.order_platform && (
|
||||
<button
|
||||
onClick={() => onToggle(tx.id)}
|
||||
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
|
||||
title={isExpanded ? "Hide receipt" : "Show the receipt this came from"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||
</div>
|
||||
{tx.effective_merchant && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
)}
|
||||
{tx.notes && (
|
||||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
{/* Category is the effective one — the override wins over the
|
||||
extracted value, the same COALESCE every other view uses,
|
||||
so a correction made elsewhere shows up here too. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{tx.effective_category ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
|
||||
title={formatCategory(tx.effective_category)}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
|
||||
/>
|
||||
{formatCategory(tx.effective_category)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">uncategorised</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
|
||||
{tx.currency !== "AUD" && (
|
||||
// Splits settle on the AUD figure, so show it next to the
|
||||
// native one rather than leaving the two to differ silently.
|
||||
<span className="block text-xs font-normal text-zinc-500">
|
||||
{tx.amount_unconverted
|
||||
? "AUD value unknown"
|
||||
: `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Whose money actually left. This is the effective owner —
|
||||
COALESCE(t.owner_id, s.owner_id) — so it is the account the
|
||||
spend came out of, which is what every balance on this page
|
||||
is computed from. "Me" matches the split chips rather than
|
||||
printing your own name twice in one row. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className={`text-xs ${tx.owner_id === meId ? "text-zinc-400" : "text-indigo-300"}`}>
|
||||
{tx.owner_id === meId ? "Me" : tx.owner_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{splits.map((s) => (
|
||||
<span key={s.participant_id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||
{s.participant_id === meId ? "Me" : s.name} {s.share_percent}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => onEdit(tx)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<OrderDetails transactionId={tx.id} currency={tx.currency ?? null} bare />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
@@ -328,7 +451,9 @@ export default function SharedPage() {
|
||||
// already does that properly, and typing "sonu" matching every row she is
|
||||
// split on would make the box look broken. The payer IS matched, because
|
||||
// nothing else on the page filters by who paid.
|
||||
const transactions = [...rawTransactions]
|
||||
const transactions = useMemo(
|
||||
() =>
|
||||
[...rawTransactions]
|
||||
.filter((tx) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
@@ -344,7 +469,9 @@ export default function SharedPage() {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
}),
|
||||
[rawTransactions, search, sortCol, sortDir]
|
||||
);
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
@@ -375,6 +502,15 @@ export default function SharedPage() {
|
||||
// viewer is a split participant, so /api/transactions/[id]/order
|
||||
// authorises them — the item list is part of what was shared.
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
// Stable identity so SharedTxRow's memo holds — an inline closure here would
|
||||
// change every render and re-render all 1,279 rows anyway.
|
||||
const toggleExpanded = useCallback((id: number) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -545,108 +681,16 @@ export default function SharedPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
return (
|
||||
<Fragment key={tx.id}>
|
||||
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{tx.order_platform && (
|
||||
<button
|
||||
onClick={() => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tx.id)) next.delete(tx.id); else next.add(tx.id);
|
||||
return next;
|
||||
})}
|
||||
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
|
||||
title={expanded.has(tx.id) ? "Hide receipt" : "Show the receipt this came from"}
|
||||
aria-expanded={expanded.has(tx.id)}
|
||||
>
|
||||
{expanded.has(tx.id) ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||
</div>
|
||||
{tx.effective_merchant && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
)}
|
||||
{tx.notes && (
|
||||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
{/* Category is the effective one — the override wins over the
|
||||
extracted value, the same COALESCE every other view uses,
|
||||
so a correction made elsewhere shows up here too. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{tx.effective_category ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
|
||||
title={formatCategory(tx.effective_category)}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
|
||||
{(transactions as SharedTransactionRow[]).map((tx) => (
|
||||
<SharedTxRow
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
isExpanded={expanded.has(tx.id)}
|
||||
meId={me?.id}
|
||||
onToggle={toggleExpanded}
|
||||
onEdit={setEditModal}
|
||||
/>
|
||||
{formatCategory(tx.effective_category)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">uncategorised</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
|
||||
{tx.currency !== "AUD" && (
|
||||
// Splits settle on the AUD figure, so show it next to the
|
||||
// native one rather than leaving the two to differ silently.
|
||||
<span className="block text-xs font-normal text-zinc-500">
|
||||
{tx.amount_unconverted
|
||||
? "AUD value unknown"
|
||||
: `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Whose money actually left. This is the effective owner —
|
||||
COALESCE(t.owner_id, s.owner_id) — so it is the account the
|
||||
spend came out of, which is what every balance on this page
|
||||
is computed from. "Me" matches the split chips rather than
|
||||
printing your own name twice in one row. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className={`text-xs ${tx.owner_id === me?.id ? "text-zinc-400" : "text-indigo-300"}`}>
|
||||
{tx.owner_id === me?.id ? "Me" : tx.owner_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{splits.map((s) => (
|
||||
<span key={s.participant_id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||
{s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setEditModal(tx)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded.has(tx.id) && (
|
||||
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<OrderDetails transactionId={tx.id} currency={tx.currency ?? null} bare />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user