fix(mobile): avoid unsupported Hermes array sorting (#16506)

This commit is contained in:
Brennan Benson
2026-08-25 17:11:09 -07:00
committed by GitHub
parent c8567eb16e
commit d2a35eebe3
7 changed files with 132 additions and 13 deletions
+5 -2
View File
@@ -7,7 +7,10 @@ import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-mo
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
} from '../worktree/use-retired-worktree-names'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import {
getMobileWorkspaceRepoBadgeColor,
@@ -89,7 +92,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
detectedAgentIds: executionTarget.detectedAgentIds
})
const retiredNamesRefreshKey = useMemo(
() => (existingWorktreePaths ?? []).toSorted().join('\0'),
() => buildRetiredWorktreeNamesRefreshKey(existingWorktreePaths),
[existingWorktreePaths]
)
const retiredWorktreeNames = useRetiredWorktreeNames(
@@ -0,0 +1,65 @@
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
blankStringContents,
blankStringContentsDesynced,
isTestFile,
scanSourceTree,
stripComments,
type ScannedFile
} from '../../src/shared/source-scan/source-tree-scan'
const SOURCE_ROOTS = [
{ label: 'mobile/src', path: import.meta.dirname },
{ label: 'mobile/app', path: resolve(import.meta.dirname, '../app') },
{ label: 'src/shared', path: resolve(import.meta.dirname, '../../src/shared') }
]
const UNSUPPORTED_ARRAY_SORTING = /\.toSorted\s*\(/
function usesUnsupportedArraySorting(source: string): boolean {
if (!UNSUPPORTED_ARRAY_SORTING.test(source)) {
return false
}
const decommented = stripComments(source)
return (
blankStringContentsDesynced(decommented) ||
UNSUPPORTED_ARRAY_SORTING.test(blankStringContents(decommented))
)
}
function findUnsupportedArraySorting(files: readonly ScannedFile[]): string[] {
return files
.filter((file) => !isTestFile(file.relativePath))
.filter((file) => usesUnsupportedArraySorting(file.source))
.map((file) => file.relativePath)
}
describe('Hermes array sorting compatibility', () => {
it('detects live copied-array sorting while ignoring inert text and tests', () => {
expect(
findUnsupportedArraySorting([
{ path: 'live.ts', relativePath: 'live.ts', source: 'const sorted = items.toSorted()' },
{
path: 'inert.ts',
relativePath: 'inert.ts',
source: "// items.toSorted()\nconst example = 'items.toSorted()'"
},
{
path: 'allowed.test.ts',
relativePath: 'allowed.test.ts',
source: 'items.toSorted()'
}
])
).toEqual(['live.ts'])
})
it('keeps production mobile bundle sources free of unsupported copied-array sorting', () => {
const offenders = SOURCE_ROOTS.flatMap(({ label, path }) =>
findUnsupportedArraySorting(scanSourceTree(path, { includeTests: true })).map(
(relativePath) => `${label}/${relativePath}`
)
)
expect(offenders).toEqual([])
})
})
+6 -9
View File
@@ -11,7 +11,10 @@ import {
} from '../onboarding/mobile-onboarding-plan'
import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { selectConnectableHostProfiles } from '../transport/host-catalog-selection'
import {
selectConnectableHostProfiles,
sortHostsByLastConnected
} from '../transport/host-catalog-selection'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry, HostProfile } from '../transport/types'
import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch'
@@ -127,14 +130,8 @@ export function useMobileHomeData() {
}, [router])
)
const sortedHosts = useMemo(
() => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hosts]
)
const sortedHostCatalog = useMemo(
() => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hostCatalog]
)
const sortedHosts = useMemo(() => sortHostsByLastConnected(hosts), [hosts])
const sortedHostCatalog = useMemo(() => sortHostsByLastConnected(hostCatalog), [hostCatalog])
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const resumeCard = useMemo(
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { selectConnectableHostProfiles } from './host-catalog-selection'
import { selectConnectableHostProfiles, sortHostsByLastConnected } from './host-catalog-selection'
import type { HostCatalogEntry, HostProfile } from './types'
const profile: HostProfile = {
@@ -36,4 +36,27 @@ describe('selectConnectableHostProfiles', () => {
])
).toEqual([profile])
})
it('sorts hosts by recency without copied-array methods or input mutation', () => {
const hosts = [
{ ...profile, id: 'oldest', lastConnected: 1 },
{ ...profile, id: 'newest', lastConnected: 3 },
{ ...profile, id: 'middle', lastConnected: 2 }
]
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toSorted')
Reflect.deleteProperty(Array.prototype, 'toSorted')
try {
expect(sortHostsByLastConnected(hosts).map((host) => host.id)).toEqual([
'newest',
'middle',
'oldest'
])
expect(hosts.map((host) => host.id)).toEqual(['oldest', 'newest', 'middle'])
} finally {
if (descriptor) {
Reflect.defineProperty(Array.prototype, 'toSorted', descriptor)
}
}
})
})
@@ -5,3 +5,9 @@ export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry
entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : []
)
}
export function sortHostsByLastConnected<T extends { lastConnected: number }>(
hosts: readonly T[]
): T[] {
return [...hosts].sort((left, right) => right.lastConnected - left.lastConnected)
}
@@ -6,7 +6,10 @@ import {
EMPTY_RETIRED_NAME_REGISTRY,
type RetiredNameRegistry
} from '../../../src/shared/worktree/retired-name-registry'
import { useRetiredWorktreeNames } from './use-retired-worktree-names'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
} from './use-retired-worktree-names'
type Pending = { resolve: (response: unknown) => void; reject: (err: Error) => void }
@@ -66,6 +69,22 @@ function mountNames() {
}
describe('useRetiredWorktreeNames', () => {
it('builds a stable refresh key without copied-array methods or input mutation', () => {
const paths = ['/repo/zebra', '/repo/antelope']
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toSorted')
Reflect.deleteProperty(Array.prototype, 'toSorted')
try {
expect(buildRetiredWorktreeNamesRefreshKey(paths)).toBe('/repo/antelope\0/repo/zebra')
expect(buildRetiredWorktreeNamesRefreshKey(undefined)).toBe('')
expect(paths).toEqual(['/repo/zebra', '/repo/antelope'])
} finally {
if (descriptor) {
Reflect.defineProperty(Array.prototype, 'toSorted', descriptor)
}
}
})
it('reads the selected repo out of the response envelope', async () => {
const probe = mountNames()
expect(probe.requests[0]).toEqual({
@@ -8,6 +8,12 @@ import {
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { RpcClient } from '../transport/rpc-client'
export function buildRetiredWorktreeNamesRefreshKey(
existingWorktreePaths: readonly string[] | undefined
): string {
return [...(existingWorktreePaths ?? [])].sort().join('\0')
}
/** Names already spent in a repo, including workspaces that have since been deleted.
*
* Why a targeted request rather than the workspace catalog: the catalog is served by `worktree.ps`,