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. */}
+
+ {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. */}
+
- {/* 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. */}
-
- {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. */}
-