perf(mobile): reuse Linear issue ordering when grouping list and board (#20314)

This commit is contained in:
Neil
2026-09-12 21:19:00 -07:00
committed by GitHub
parent 392583caba
commit df375cdd8a
6 changed files with 199 additions and 14 deletions
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
const baseline = process.argv[2]
if (!baseline) {
throw new Error(
'Usage: node config/scripts/mobile-linear-group-sorted-benchmark.mjs <baseline-ref>'
)
}
async function load(file, contents) {
const result = await build({
stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) },
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent',
tsconfigRaw: {},
plugins: [
{
name: 'theme-only',
setup(bundler) {
bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({
path: resolve('mobile/src/theme/mobile-theme.ts')
}))
}
}
]
})
return await import(
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`
)
}
const file = 'mobile/src/tasks/mobile-tasks-reviewer-linear.ts'
const original = await load(
file,
execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true })
)
const current = await load(file, readFileSync(file, 'utf8'))
const sorterRef = process.argv[3]
const sorter = sorterRef
? await load(
file,
execFileSync('git', ['show', `${sorterRef}:${file}`], { encoding: 'utf8', windowsHide: true })
)
: original
const results = []
for (const count of [25, 200, 1000]) {
const items = Array.from({ length: count }, (_, i) => ({
id: `item-${i}`,
identifier: `ENG-${(i * 37) % count}`,
updatedAt: new Date(1700000000000 - i * 100000).toISOString(),
priority: i % 5,
state: { name: `state-${i % 4}`, color: 'red' },
team: { id: `team-${i % 3}`, name: 'Team' },
assignee: null
}))
for (const order of ['identifier', 'updated', 'priority']) {
const run = (arm) => {
const sorted = sorter.sortLinearIssues
? sorter.sortLinearIssues(items, order)
: [...items].sort((a, b) => sorter.compareLinearIssues(a, b, order))
return arm === 'before'
? [
sorter.groupLinearIssues(sorted, 'none', order),
sorter.groupLinearIssues(sorted, 'status', order)
]
: [
current.groupSortedLinearIssues(sorted, 'none'),
current.groupSortedLinearIssues(sorted, 'status')
]
}
assert.deepEqual(run('after'), run('before'))
for (let i = 0; i < 10; i++) {
run('before')
run('after')
}
const samples = { before: [], after: [] }
for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) {
for (const arm of pair) {
global.gc?.()
const start = performance.now()
for (let i = 0; i < 10; i++) {
run(arm)
}
samples[arm].push((performance.now() - start) / 10)
}
}
results.push({ count, order, samples })
}
}
console.log(JSON.stringify({ baseline, sorterRef, node: process.version, results }, null, 2))
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LinearIssue } from './mobile-tasks-provider-detail-types'
import {
sortLinearIssues,
groupLinearIssues,
groupSortedLinearIssues
} from './mobile-tasks-reviewer-linear'
vi.mock('./mobile-tasks-dependencies', () => import('../theme/mobile-theme'))
afterEach(() => vi.restoreAllMocks())
const issues: LinearIssue[] = Array.from({ length: 60 }, (_, i) => ({
id: `${i}`,
identifier: ['ENG-10', 'ENG-2', 'Ä-1', 'Å-1', 'é-2', 'e\u0301-2', 'İ-3'][i % 7],
title: 'Task',
url: '',
labels: [],
priority: i % 5,
updatedAt: ['2026-02-01', 'invalid', '1970-01-01', '2026-02-01', '2025-01-01'][
Math.floor(i / 5) % 5
],
state: { name: i % 2 ? 'Todo' : 'Done', type: 'started', color: '' },
team: { id: `${i % 3}`, name: `Team ${i % 3}`, key: 'ENG' }
}))
describe('mobile Linear grouping of sorted issues', () => {
it.each(['updated', 'identifier', 'priority'] as const)(
'preserves %s ordering, ties and group metadata',
(order) => {
const sorted = Object.freeze(sortLinearIssues(issues, order))
for (const group of ['none', 'status', 'assignee', 'team', 'priority'] as const) {
const expected = groupLinearIssues([...sorted], group, order)
const actual = groupSortedLinearIssues(sorted, group)
expect(actual).toEqual(expected)
actual.forEach((section, index) => {
expect(section.issues).not.toBe(sorted)
section.issues.forEach((issue, offset) =>
expect(issue).toBe(expected[index].issues[offset])
)
})
}
}
)
it('does no date parsing or collation after ordering has been established', () => {
const sorted = sortLinearIssues(issues, 'updated')
const parse = vi.spyOn(Date, 'parse')
const compare = vi.spyOn(String.prototype, 'localeCompare')
groupSortedLinearIssues(sorted, 'none')
groupSortedLinearIssues(sorted, 'status')
expect(parse).not.toHaveBeenCalled()
expect(compare).not.toHaveBeenCalled()
groupLinearIssues(sorted, 'status', 'updated')
expect(parse).toHaveBeenCalled()
})
it('returns independent issue arrays for empty, singleton and ungrouped inputs', () => {
for (const input of [[], [issues[0]], issues]) {
const sorted = Object.freeze([...input])
const first = groupSortedLinearIssues(sorted, 'none')
const second = groupSortedLinearIssues(sorted, 'none')
expect(first).toEqual(second)
expect(first[0].issues).not.toBe(second[0].issues)
first[0].issues.pop()
expect(second[0].issues).toEqual(sorted)
}
expect(groupSortedLinearIssues([], 'status')).toEqual([])
})
})
@@ -17,10 +17,10 @@ const hash = (parts: string[] | string): string =>
.digest('hex')
// Task and Linear sort tests cover computation changes; render/style guards remain.
const EXPECTED_SCREEN_HOOKS = '09b5710f48b0421dfaa86b622e6223df6317f097decc384b05006edfdbe4e98a'
const EXPECTED_SCREEN_HOOKS = '25c9a72805e48caa9c6758d14a128fea1bdc6cfdea3e4e39a7f0026c5933c8d7'
const EXPECTED_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
const EXPECTED_STATEMENTS = 'd10837338d241c11e20789233744f95389773d59063d8736d8978a15e0e55a59'
const EXPECTED_DECLARATIONS = '3fb5a15c92960124ea2b9a222d8ed4786b1faab965d56a7a8896cc7ea44a6afc'
const EXPECTED_STATEMENTS = '1413f6e843f7a7ae849767b26b25d5eafc7fffda1fc37f283fbd883d9d8a4bda'
const EXPECTED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415'
const EXPECTED_SEMANTICS = '4758ba019e4ff7cadd7ee02338719fa4fc4e1443e34cc290842819cfa1a70181'
const EXPECTED_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
const EXPECTED_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f'
@@ -44,7 +44,7 @@ describe('Mobile Tasks refactor parity', () => {
it('preserves every moved top-level declaration', () => {
const declarations = readMobileTasksDeclarationSignatures()
expect(declarations).toHaveLength(192)
expect(declarations).toHaveLength(194)
expect(hash(declarations)).toBe(EXPECTED_DECLARATIONS)
})
@@ -139,7 +139,21 @@ export function groupLinearIssues(
groupBy: LinearGroupBy,
orderBy: LinearOrderBy
): LinearIssueSection[] {
const sorted = sortLinearIssues(issues, orderBy)
return groupOrderedLinearIssues(sortLinearIssues(issues, orderBy), groupBy)
}
/** The caller must sort issues by its selected order before grouping. */
export function groupSortedLinearIssues(
issues: readonly LinearIssue[],
groupBy: LinearGroupBy
): LinearIssueSection[] {
return groupOrderedLinearIssues([...issues], groupBy)
}
function groupOrderedLinearIssues(
sorted: LinearIssue[],
groupBy: LinearGroupBy
): LinearIssueSection[] {
if (groupBy === 'none') {
return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }]
}
@@ -38,6 +38,10 @@ vi.mock('./mobile-tasks-legacy-foundation', async () => {
groupLinearIssues: (issues: LinearIssue[], groupBy: LinearGroupBy, orderBy: LinearOrderBy) => {
groupingInputSizes.push(issues.length)
return linear.groupLinearIssues(issues, groupBy, orderBy)
},
groupSortedLinearIssues: (issues: readonly LinearIssue[], groupBy: LinearGroupBy) => {
groupingInputSizes.push(issues.length)
return linear.groupSortedLinearIssues(issues, groupBy)
}
}
})
@@ -392,11 +396,12 @@ describe('useMobileTasksProviderViewProjection grouping work', () => {
expect(after.groupingCalls).toBe(1)
expect(before.issueVisits).toBe(100)
expect(after.issueVisits).toBe(50)
// The dropped grouping re-sorted an already sorted 50-issue array: n-1 comparisons.
expect(before.comparisons - after.comparisons).toBe(input.items.length - 1)
// Both legacy groupings re-sorted an already sorted 50-issue array (n-1 comparisons
// each); one call is gone and the other groups without re-sorting.
expect(before.comparisons - after.comparisons).toBe(2 * (input.items.length - 1))
})
it('leaves the none grouping work untouched', () => {
it('keeps both none grouping calls but drops their re-sorts', () => {
const input = { ...DEFAULT_INPUT, linearGroupBy: 'none' as const }
const before = countLinearWork(() => {
legacyProjection(input)
@@ -406,7 +411,7 @@ describe('useMobileTasksProviderViewProjection grouping work', () => {
})
expect([before.groupingCalls, after.groupingCalls]).toEqual([2, 2])
expect([before.issueVisits, after.issueVisits]).toEqual([100, 100])
expect(after.comparisons).toBe(before.comparisons)
expect(before.comparisons - after.comparisons).toBe(2 * (input.items.length - 1))
})
it('does no grouping work on an unrelated rerender', () => {
@@ -16,7 +16,7 @@ import {
PR_PRESETS,
type TaskItem,
sortLinearIssues,
groupLinearIssues
groupSortedLinearIssues
} from './mobile-tasks-legacy-foundation'
export function useMobileTasksProviderViewProjection(model: PickerProjectionModel) {
@@ -133,8 +133,8 @@ export function useMobileTasksProviderViewProjection(model: PickerProjectionMode
[items, linearOrderBy]
)
const linearIssueSections = useMemo(
() => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy),
[linearGroupBy, linearIssuesForView, linearOrderBy]
() => groupSortedLinearIssues(linearIssuesForView, linearGroupBy),
[linearGroupBy, linearIssuesForView]
)
// Why: FlatList treats data identity as meaningful; unrelated renders should
// not rebuild the section/item wrapper array.
@@ -155,9 +155,9 @@ export function useMobileTasksProviderViewProjection(model: PickerProjectionMode
const linearBoardSections = useMemo(
() =>
linearGroupBy === 'none'
? groupLinearIssues(linearIssuesForView, 'status', linearOrderBy)
? groupSortedLinearIssues(linearIssuesForView, 'status')
: linearIssueSections,
[linearGroupBy, linearIssueSections, linearIssuesForView, linearOrderBy]
[linearGroupBy, linearIssueSections, linearIssuesForView]
)
const githubModeLabel =
githubMode === 'project' ? 'Projects' : githubKind === 'prs' ? 'PRs' : 'Issues'