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:
+165
-121
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useEffect } from "react";
|
import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
useSharedTransactions,
|
useSharedTransactions,
|
||||||
useParticipantBalances,
|
useParticipantBalances,
|
||||||
@@ -20,8 +20,12 @@ import { OrderDetails } from "@/components/order-details";
|
|||||||
import { formatCategory } from "@/lib/categories";
|
import { formatCategory } from "@/lib/categories";
|
||||||
import { CATEGORY_COLORS } from "@/lib/category-colors";
|
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) {
|
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"]);
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||||
|
|
||||||
@@ -328,23 +451,27 @@ export default function SharedPage() {
|
|||||||
// already does that properly, and typing "sonu" matching every row she is
|
// 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
|
// split on would make the box look broken. The payer IS matched, because
|
||||||
// nothing else on the page filters by who paid.
|
// nothing else on the page filters by who paid.
|
||||||
const transactions = [...rawTransactions]
|
const transactions = useMemo(
|
||||||
.filter((tx) => {
|
() =>
|
||||||
const q = search.trim().toLowerCase();
|
[...rawTransactions]
|
||||||
if (!q) return true;
|
.filter((tx) => {
|
||||||
return [
|
const q = search.trim().toLowerCase();
|
||||||
tx.description,
|
if (!q) return true;
|
||||||
tx.effective_merchant,
|
return [
|
||||||
tx.notes,
|
tx.description,
|
||||||
tx.effective_category ? formatCategory(tx.effective_category) : null,
|
tx.effective_merchant,
|
||||||
tx.owner_name,
|
tx.notes,
|
||||||
].some((f) => f?.toLowerCase().includes(q));
|
tx.effective_category ? formatCategory(tx.effective_category) : null,
|
||||||
})
|
tx.owner_name,
|
||||||
.sort((a, b) => {
|
].some((f) => f?.toLowerCase().includes(q));
|
||||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
})
|
||||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
.sort((a, b) => {
|
||||||
return sortDir === "desc" ? bv - av : av - bv;
|
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) {
|
function toggleSort(col: SortCol) {
|
||||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
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
|
// viewer is a split participant, so /api/transactions/[id]/order
|
||||||
// authorises them — the item list is part of what was shared.
|
// authorises them — the item list is part of what was shared.
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -545,108 +681,16 @@ export default function SharedPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(transactions as SharedTransactionRow[]).map((tx) => {
|
{(transactions as SharedTransactionRow[]).map((tx) => (
|
||||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
<SharedTxRow
|
||||||
return (
|
key={tx.id}
|
||||||
<Fragment key={tx.id}>
|
tx={tx}
|
||||||
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
isExpanded={expanded.has(tx.id)}
|
||||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
meId={me?.id}
|
||||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
onToggle={toggleExpanded}
|
||||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
onEdit={setEditModal}
|
||||||
<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" }}
|
|
||||||
/>
|
|
||||||
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user