From b4f28010df4dce805ab6b653e6ac994114342321 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Mon, 10 Aug 2026 19:39:19 +1000 Subject: [PATCH] =?UTF-8?q?shared=20page:=20fix=20expand=20freeze=20?= =?UTF-8?q?=E2=80=94=20cache=20date=20formatter,=20memoize=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/shared/page.tsx | 286 +++++++++++++++++++++++----------------- 1 file changed, 165 insertions(+), 121 deletions(-) diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index 3d7787c..2fd6081 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -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 ( + + + {formatDate(tx.transaction_date)} + {formatDate(tx.created_at)} + +
+ {tx.order_platform && ( + + )} +

{tx.effective_merchant || tx.description}

+
+ {tx.effective_merchant && ( +

{tx.description}

+ )} + {tx.notes && ( +

{tx.notes}

+ )} + + {/* 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. */} + + {tx.effective_category ? ( + + + {formatCategory(tx.effective_category)} + + ) : ( + uncategorised + )} + + + {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. + + {tx.amount_unconverted + ? "AUD value unknown" + : `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`} + + )} + + {/* 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. */} + + + {tx.owner_id === meId ? "Me" : tx.owner_name} + + + +
+ {splits.map((s) => ( + + {s.participant_id === meId ? "Me" : s.name} {s.share_percent}% + + ))} +
+ + + + + + {isExpanded && ( + + + + + + )} +
+ ); +}); + // ── Main page ───────────────────────────────────────────────────────────────── 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 // 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] - .filter((tx) => { - const q = search.trim().toLowerCase(); - if (!q) return true; - return [ - tx.description, - tx.effective_merchant, - tx.notes, - tx.effective_category ? formatCategory(tx.effective_category) : null, - tx.owner_name, - ].some((f) => f?.toLowerCase().includes(q)); - }) - .sort((a, b) => { - 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; - }); + const transactions = useMemo( + () => + [...rawTransactions] + .filter((tx) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + return [ + tx.description, + tx.effective_merchant, + tx.notes, + tx.effective_category ? formatCategory(tx.effective_category) : null, + tx.owner_name, + ].some((f) => f?.toLowerCase().includes(q)); + }) + .sort((a, b) => { + 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>(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 (
@@ -545,108 +681,16 @@ export default function SharedPage() { - {(transactions as SharedTransactionRow[]).map((tx) => { - const splits = Array.isArray(tx.splits) ? tx.splits : []; - return ( - - - {formatDate(tx.transaction_date)} - {formatDate(tx.created_at)} - -
- {tx.order_platform && ( - - )} -

{tx.effective_merchant || tx.description}

-
- {tx.effective_merchant && ( -

{tx.description}

- )} - {tx.notes && ( -

{tx.notes}

- )} - - {/* 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. */} - - {tx.effective_category ? ( - - - {formatCategory(tx.effective_category)} - - ) : ( - uncategorised - )} - - - {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. - - {tx.amount_unconverted - ? "AUD value unknown" - : `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`} - - )} - - {/* 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. */} - - - {tx.owner_id === me?.id ? "Me" : tx.owner_name} - - - -
- {splits.map((s) => ( - - {s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}% - - ))} -
- - - - - - {expanded.has(tx.id) && ( - - - - - - )} -
- ); - })} + {(transactions as SharedTransactionRow[]).map((tx) => ( + + ))} )}