Skip to content

Commit 7b01d82

Browse files
KyleAMathewsclaude
andcommitted
perf(linearlarge): optimize rendering and query management
- Use shared context menus instead of per-row instances to reduce portal bloat - Add query caching for issues list to prevent duplicate query instances - Replace dayjs with native Intl.DateTimeFormat and add memoization - Add loaderDeps to TanStack Router for proper search param tracking - Prevent unnecessary setWindow calls in useLiveInfiniteQuery - Normalize filter arrays for stable cache keys across collections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent bd7b310 commit 7b01d82

File tree

10 files changed

+10271
-6285
lines changed

10 files changed

+10271
-6285
lines changed

examples/react/linearlarge/src/components/IssueList.tsx

Lines changed: 35 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,50 @@
1-
import {
2-
useLiveInfiniteQuery,
3-
useLiveQuery,
4-
and,
5-
eq,
6-
inArray,
7-
} from '@tanstack/react-db'
1+
import { useLiveInfiniteQuery, useLiveQuery } from '@tanstack/react-db'
82
import { useVirtualizer } from '@tanstack/react-virtual'
9-
import { useRef, useEffect, useMemo, useCallback } from 'react'
3+
import { useCallback, useEffect, useMemo, useRef } from 'react'
104
import { useSearch } from '@tanstack/react-router'
115
import IssueRow from './IssueRow'
6+
import { TopFilter } from './TopFilter'
7+
import PriorityMenu from './contextmenu/PriorityMenu'
8+
import StatusMenu from './contextmenu/StatusMenu'
129
import { useMode } from '@/lib/mode-context'
1310
import { getFilterStateFromSearch } from '@/utils/filterState'
14-
import { TopFilter } from './TopFilter'
15-
import { getIssueCountQuery } from '@/lib/queries'
16-
17-
const PAGE_SIZE = 50
11+
import {
12+
ISSUES_PAGE_SIZE,
13+
getIssueCountQuery,
14+
getIssuesListQuery,
15+
} from '@/lib/queries'
1816

1917
export function IssueList() {
20-
const { issuesCollection } = useMode()
18+
const { issuesCollection, mode } = useMode()
2119
const parentRef = useRef<HTMLDivElement>(null)
2220
const search = useSearch({ strict: false })
2321
const filterState = useMemo(() => getFilterStateFromSearch(search), [search])
24-
25-
const issueCountQuery = getIssueCountQuery({
26-
status: filterState.status,
27-
priority: filterState.priority,
28-
})
29-
30-
const { data: countData } = useLiveQuery(() => issueCountQuery, [search])
31-
32-
const totalCount = countData?.[0]?.count
22+
const issuesQuery = useMemo(
23+
() => getIssuesListQuery(filterState, mode),
24+
[filterState, mode]
25+
)
26+
const issueCountQuery = useMemo(
27+
() => getIssueCountQuery(filterState),
28+
[filterState]
29+
)
30+
const { data: countData } = useLiveQuery(issueCountQuery)
31+
const totalCount = countData[0].count
3332

3433
const {
3534
data: issues,
3635
status,
3736
hasNextPage,
3837
fetchNextPage,
3938
isFetchingNextPage,
40-
pages,
41-
} = useLiveInfiniteQuery(
42-
(q) => {
43-
let query = q.from({ issue: issuesCollection })
44-
45-
// Apply filters using declarative expressions
46-
if (filterState.status?.length || filterState.priority?.length) {
47-
query = query.where(({ issue }) => {
48-
const conditions = []
49-
50-
if (filterState.status?.length) {
51-
// Use inArray for multiple values or eq for single value
52-
if (filterState.status.length === 1) {
53-
conditions.push(eq(issue.status, filterState.status[0]))
54-
} else {
55-
conditions.push(inArray(issue.status, filterState.status))
56-
}
57-
}
58-
59-
if (filterState.priority?.length) {
60-
// Use inArray for multiple values or eq for single value
61-
if (filterState.priority.length === 1) {
62-
conditions.push(eq(issue.priority, filterState.priority[0]))
63-
} else {
64-
conditions.push(inArray(issue.priority, filterState.priority))
65-
}
66-
}
67-
68-
return conditions.length === 1 ? conditions[0] : and(...conditions)
69-
})
39+
} = useLiveInfiniteQuery(issuesQuery, {
40+
pageSize: ISSUES_PAGE_SIZE,
41+
getNextPageParam: (lastPage, allPages) => {
42+
if (lastPage.length === ISSUES_PAGE_SIZE) {
43+
return allPages.length
7044
}
71-
72-
// Apply ordering
73-
const orderField =
74-
filterState.orderBy === 'created_at' ? 'created_at' : 'modified'
75-
return query.orderBy(
76-
({ issue }) => issue[orderField],
77-
filterState.orderDirection
78-
)
79-
},
80-
{
81-
pageSize: PAGE_SIZE,
82-
staleTime: 5 * 60 * 1000, // 5 minutes
83-
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
84-
getNextPageParam: (lastPage, allPages) => {
85-
// Continue fetching as long as the last page was full
86-
// This is the standard infinite scroll pattern
87-
if (lastPage.length === PAGE_SIZE) {
88-
return allPages.length // Return next page index
89-
}
90-
// If we got fewer items than pageSize, we've reached the end
91-
return undefined
92-
},
45+
return undefined
9346
},
94-
[search]
95-
)
96-
97-
console.log(`render`, { issues })
47+
})
9848

9949
// Memoize getScrollElement to avoid recreating virtualizer
10050
const getScrollElement = useCallback(() => parentRef.current, [])
@@ -103,12 +53,12 @@ export function IssueList() {
10353
count: totalCount ?? issues.length,
10454
getScrollElement,
10555
estimateSize: () => 36,
106-
overscan: 50,
56+
overscan: 25,
10757
})
10858

10959
// Reset virtualizer to top when filters change
11060
useEffect(() => {
111-
virtualizer.scrollToIndex(0, { align: 'start' })
61+
virtualizer.scrollToIndex(0, { align: `start` })
11262
}, [
11363
filterState.status,
11464
filterState.priority,
@@ -122,7 +72,7 @@ export function IssueList() {
12272
const lastVirtualItem = virtualItems[virtualItems.length - 1]
12373

12474
useEffect(() => {
125-
if (!lastVirtualItem) return
75+
if (virtualItems.length === 0) return
12676

12777
const loadedCount = issues.length
12878

@@ -134,13 +84,12 @@ export function IssueList() {
13484
fetchNextPage()
13585
}
13686
}, [
87+
virtualItems.length,
13788
lastVirtualItem,
13889
hasNextPage,
13990
fetchNextPage,
14091
isFetchingNextPage,
14192
issues.length,
142-
totalCount,
143-
pages.length,
14493
])
14594

14695
if (status === `loading`) {
@@ -231,6 +180,9 @@ export function IssueList() {
231180
)}
232181
</div>
233182
</div>
183+
{/* Shared context menus (render once instead of per row to avoid portal bloat) */}
184+
<PriorityMenu issuesCollection={issuesCollection} />
185+
<StatusMenu issuesCollection={issuesCollection} />
234186
</>
235187
)
236188
}

examples/react/linearlarge/src/components/IssueRow.tsx

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,21 @@
11
import type { CSSProperties } from 'react'
2-
import { BsCloudCheck as SyncedIcon } from 'react-icons/bs'
3-
import { BsCloudSlash as UnsyncedIcon } from 'react-icons/bs'
4-
import PriorityMenu from './contextmenu/PriorityMenu'
5-
import StatusMenu from './contextmenu/StatusMenu'
62
import PriorityIcon from './PriorityIcon'
73
import StatusIcon from './StatusIcon'
84
import Avatar from './Avatar'
95
import { memo } from 'react'
106
import { Link } from '@tanstack/react-router'
7+
import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu'
118
import { formatDate } from '../utils/date'
129
import type { Issue } from '@/db/schema'
13-
import { useMode } from '@/lib/mode-context'
10+
import { PRIORITY_MENU_ID } from './contextmenu/PriorityMenu'
11+
import { STATUS_MENU_ID } from './contextmenu/StatusMenu'
1412

1513
interface Props {
1614
issue: Issue | undefined
1715
style: CSSProperties
1816
}
1917

2018
function IssueRow({ issue, style }: Props) {
21-
const { issuesCollection } = useMode()
22-
23-
const handleChangeStatus = async (status: string) => {
24-
if (!issue?.id) return
25-
issuesCollection.update(issue.id, { status: status as Issue['status'] })
26-
}
27-
28-
const handleChangePriority = async (priority: string) => {
29-
if (!issue?.id) return
30-
issuesCollection.update(issue.id, {
31-
priority: priority as Issue['priority'],
32-
})
33-
}
34-
3519
if (!issue?.id) {
3620
return (
3721
<div
@@ -50,28 +34,32 @@ function IssueRow({ issue, style }: Props) {
5034
id={issue.id}
5135
style={style}
5236
>
53-
<div className="flex-shrink-0 ml-4">
54-
<PriorityMenu
55-
id={`r-priority-` + issue.id}
56-
button={<PriorityIcon priority={issue.priority} />}
57-
onSelect={handleChangePriority}
58-
/>
59-
</div>
60-
<div className="flex-shrink-0 ml-3">
61-
<StatusMenu
62-
id={`r-status-` + issue.id}
63-
button={<StatusIcon status={issue.status} />}
64-
onSelect={handleChangeStatus}
65-
/>
66-
</div>
37+
<ContextMenuTrigger
38+
id={PRIORITY_MENU_ID}
39+
collect={() => ({ issueId: issue.id, priority: issue.priority })}
40+
holdToDisplay={-1}
41+
triggerOnLeftClick
42+
attributes={{ className: 'flex-shrink-0 ml-4 cursor-pointer' }}
43+
>
44+
<PriorityIcon priority={issue.priority} />
45+
</ContextMenuTrigger>
46+
<ContextMenuTrigger
47+
id={STATUS_MENU_ID}
48+
collect={() => ({ issueId: issue.id, status: issue.status })}
49+
holdToDisplay={-1}
50+
triggerOnLeftClick
51+
attributes={{ className: 'flex-shrink-0 ml-3 cursor-pointer' }}
52+
>
53+
<StatusIcon status={issue.status} />
54+
</ContextMenuTrigger>
6755
<Link
6856
to="/issue/$issueId"
6957
params={{ issueId: issue.id }}
7058
search={(prev) => prev}
7159
className="flex items-center flex-grow min-w-0 h-full"
7260
>
7361
<div className="flex-wrap flex-shrink ml-3 overflow-hidden font-medium line-clamp-1 overflow-ellipsis">
74-
{issue.title.slice(0, 3000) || ''}
62+
{issue.title || ''}
7563
</div>
7664
<div className="flex-shrink-0 hidden w-15 ml-auto font-normal text-gray-500 sm:block whitespace-nowrap">
7765
{formatDate(issue.created_at)}
Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
1-
import { Portal } from '../Portal'
2-
import { ReactNode, useState } from 'react'
3-
import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu'
1+
import { useState } from 'react'
2+
import { connectMenu, type ConnectMenuProps } from '@firefox-devtools/react-contextmenu'
3+
import type { Collection } from '@tanstack/db'
4+
import type { Issue } from '@/db/schema'
45
import { Menu } from './menu'
56
import { PriorityOptions } from '../../types/types'
7+
import { Portal } from '../Portal'
68

7-
interface Props {
8-
id: string
9-
button: ReactNode
10-
filterKeyword?: boolean
11-
className?: string
12-
onSelect?: (item: string) => void
9+
export const PRIORITY_MENU_ID = 'priority-menu'
10+
11+
interface PriorityMenuProps extends ConnectMenuProps {
12+
issuesCollection: Collection<Issue>
1313
}
1414

15-
function PriorityMenu({
16-
id,
17-
button,
18-
filterKeyword = false,
19-
className,
20-
onSelect,
21-
}: Props) {
15+
function PriorityMenuBase({ trigger, issuesCollection }: PriorityMenuProps) {
2216
const [keyword, setKeyword] = useState(``)
17+
const data = (trigger?.data || {}) as {
18+
issueId?: string
19+
priority?: Issue['priority']
20+
}
2321

24-
const handleSelect = (priority: string) => {
22+
const handleSelect = async (priority: string) => {
2523
setKeyword(``)
26-
if (onSelect) onSelect(priority)
24+
if (!data.issueId) return
25+
await issuesCollection.update(data.issueId, {
26+
priority: priority as Issue['priority'],
27+
})
2728
}
29+
2830
let statusOpts = PriorityOptions
2931
if (keyword !== ``) {
3032
const normalizedKeyword = keyword.toLowerCase().trim()
@@ -35,36 +37,36 @@ function PriorityMenu({
3537
}
3638

3739
const options = statusOpts.map(([Icon, priority, label]) => {
40+
const isActive = data.priority === priority
3841
return (
3942
<Menu.Item
4043
key={`priority-${priority}`}
4144
onClick={() => handleSelect(priority as string)}
4245
>
43-
<Icon className="mr-3" /> <span>{label}</span>
46+
<Icon className="mr-3" />
47+
<span className={isActive ? 'font-semibold text-gray-800' : undefined}>
48+
{label}
49+
</span>
4450
</Menu.Item>
4551
)
4652
})
4753

4854
return (
49-
<>
50-
<ContextMenuTrigger id={id} holdToDisplay={1}>
51-
{button}
52-
</ContextMenuTrigger>
53-
54-
<Portal>
55-
<Menu
56-
id={id}
57-
size="small"
58-
filterKeyword={filterKeyword}
59-
searchPlaceholder="Set priority..."
60-
onKeywordChange={(kw) => setKeyword(kw)}
61-
className={className}
62-
>
63-
{options}
64-
</Menu>
65-
</Portal>
66-
</>
55+
<Portal>
56+
<Menu
57+
id={PRIORITY_MENU_ID}
58+
size="small"
59+
filterKeyword={true}
60+
searchPlaceholder="Set priority..."
61+
onKeywordChange={(kw) => setKeyword(kw)}
62+
className="max-h-[60vh] overflow-y-auto"
63+
>
64+
{options}
65+
</Menu>
66+
</Portal>
6767
)
6868
}
6969

70+
const PriorityMenu = connectMenu(PRIORITY_MENU_ID)(PriorityMenuBase)
71+
7072
export default PriorityMenu

0 commit comments

Comments
 (0)