shared page: fix expand freeze — cache date formatter, memoize rows
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:
2026-08-10 19:39:19 +10:00
parent 0e5422919e
commit b4f28010df
+148 -104
View File
@@ -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>
)}