- {photos.map((photo, index) => {
- const isActive = index === currentIndex
- const isInActiveHeap = activeHeapMembers.has(photo.id)
- return (
-
- )
- })}
+
+
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const photo = photos[vItem.index]
+ const isActive = vItem.index === currentIndex
+ const isInActiveHeap = activeHeapMembers.has(photo.id)
+ return (
+
+ )
+ })}
+
)
}
diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx
index 9e4be4f..6d1cffd 100644
--- a/frontend/src/components/timeline/Timeline.tsx
+++ b/frontend/src/components/timeline/Timeline.tsx
@@ -40,7 +40,6 @@ type TimelineItem =
function buildItems(
photos: Photo[],
columns: number,
- rowHeight: number,
sortBy: string,
groupBy: string,
): TimelineItem[] {
@@ -48,7 +47,9 @@ function buildItems(
const items: TimelineItem[] = []
- // Helper: split a flat array of cells into rows of `columns` cells.
+ // Row items carry a placeholder `height: 0` — the virtualizer reads
+ // the live cellSize off a ref at render time (see `estimateSize`),
+ // so resizing the main column doesn't force items to rebuild.
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
for (let i = 0; i < cells.length; i += columns) {
const slice = cells.slice(i, i + columns)
@@ -56,7 +57,7 @@ function buildItems(
type: 'row',
key: `${groupKey}::row::${i}`,
cells: slice,
- height: rowHeight + GAP,
+ height: 0,
})
}
}
@@ -232,11 +233,28 @@ export function Timeline() {
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
+ //
+ // Notably NOT dependent on `cellSize` — row heights are read off a ref
+ // at virtualizer-measurement time instead. This stops `items` (and
+ // every downstream memo) from rebuilding on every sub-pixel tick of
+ // the sidebar CSS transition, which used to cause visible jank in
+ // timelines with thousands of photos.
const items = useMemo(
- () => buildItems(photos, columns, cellSize, sortBy, groupBy),
- [photos, columns, cellSize, sortBy, groupBy]
+ () => buildItems(photos, columns, sortBy, groupBy),
+ [photos, columns, sortBy, groupBy]
)
+ // Live cell size, consumed by the virtualizer's estimateSize so row
+ // heights stay accurate as the column fluidly resizes.
+ const cellSizeRef = useRef(cellSize)
+ cellSizeRef.current = cellSize
+
+ // Row items carry a placeholder height of 0 (see buildItems). Any
+ // code walking `items` for scroll offsets must resolve it against the
+ // current cellSize; headers carry their fixed height verbatim.
+ const effectiveHeight = (it: TimelineItem) =>
+ it.type === 'header' ? it.height : cellSizeRef.current + GAP
+
// Used by the sticky header, the row-date index, and the floating
// scrollbar chip — all three only make sense in date-sorted views.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
@@ -259,14 +277,23 @@ export function Timeline() {
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
- estimateSize: (index) => items[index]?.height ?? cellSize,
+ estimateSize: (index) => {
+ const it = items[index]
+ if (!it) return cellSizeRef.current + GAP
+ // Row items carry `height: 0` as a placeholder — always resolved
+ // to the current cellSize via the ref. Headers carry their own
+ // fixed height.
+ return it.type === 'header' ? it.height : cellSizeRef.current + GAP
+ },
overscan: 5,
})
- // Re-measure when items change (column count, group structure).
+ // Re-measure when items change (column count, group structure) or
+ // cellSize shifts (sidebar collapse, window resize). Cheap — it just
+ // walks the item list and recomputes heights.
useEffect(() => {
virtualizer.measure()
- }, [items, virtualizer])
+ }, [items, cellSize, virtualizer])
// Track scroll position so we can (a) show the current group label as
// a pinned overlay at the top of the scroll container and (b) drive
@@ -490,8 +517,8 @@ export function Timeline() {
const scrollEl = parentRef.current
if (itemIdx === undefined || !scrollEl) return
let rowTop = 0
- for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
- const rowHeight = items[itemIdx].height
+ for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
+ const rowHeight = effectiveHeight(items[itemIdx])
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
@@ -552,8 +579,8 @@ export function Timeline() {
// virtualizer's coordinate space. Cheap enough at O(items) and
// avoids reaching into virtualizer.measurementsCache internals.
let rowTop = 0
- for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
- const rowHeight = items[itemIdx].height
+ for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
+ const rowHeight = effectiveHeight(items[itemIdx])
const peek = Math.round(cellSize * 0.35)
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx
index 2ac5cd3..2cc3bb5 100644
--- a/frontend/src/components/ui/sonner.tsx
+++ b/frontend/src/components/ui/sonner.tsx
@@ -10,16 +10,20 @@ const Toaster = ({ ...props }: ToasterProps) => (