From d2a35eebe3876c6295a7cb38470500b170edbdc4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:11:09 -0700 Subject: [PATCH] fix(mobile): avoid unsupported Hermes array sorting (#16506) --- mobile/src/components/NewWorktreeModal.tsx | 7 +- .../src/hermes-array-sorting-compat.test.ts | 65 +++++++++++++++++++ mobile/src/home/use-mobile-home-data.ts | 15 ++--- .../transport/host-catalog-selection.test.ts | 25 ++++++- .../src/transport/host-catalog-selection.ts | 6 ++ .../use-retired-worktree-names.test.tsx | 21 +++++- .../worktree/use-retired-worktree-names.ts | 6 ++ 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 mobile/src/hermes-array-sorting-compat.test.ts diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index a6a6f1fb9bd..92bac7d59bb 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -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( diff --git a/mobile/src/hermes-array-sorting-compat.test.ts b/mobile/src/hermes-array-sorting-compat.test.ts new file mode 100644 index 00000000000..d28888d3082 --- /dev/null +++ b/mobile/src/hermes-array-sorting-compat.test.ts @@ -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([]) + }) +}) diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts index 4061d885bc2..706b54499d4 100644 --- a/mobile/src/home/use-mobile-home-data.ts +++ b/mobile/src/home/use-mobile-home-data.ts @@ -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( diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts index 5f5d85ca787..ea5d2246fbe 100644 --- a/mobile/src/transport/host-catalog-selection.test.ts +++ b/mobile/src/transport/host-catalog-selection.test.ts @@ -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) + } + } + }) }) diff --git a/mobile/src/transport/host-catalog-selection.ts b/mobile/src/transport/host-catalog-selection.ts index 31477dd7f6d..f1bd01a5770 100644 --- a/mobile/src/transport/host-catalog-selection.ts +++ b/mobile/src/transport/host-catalog-selection.ts @@ -5,3 +5,9 @@ export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : [] ) } + +export function sortHostsByLastConnected( + hosts: readonly T[] +): T[] { + return [...hosts].sort((left, right) => right.lastConnected - left.lastConnected) +} diff --git a/mobile/src/worktree/use-retired-worktree-names.test.tsx b/mobile/src/worktree/use-retired-worktree-names.test.tsx index 9258e530807..660b2f018d9 100644 --- a/mobile/src/worktree/use-retired-worktree-names.test.tsx +++ b/mobile/src/worktree/use-retired-worktree-names.test.tsx @@ -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({ diff --git a/mobile/src/worktree/use-retired-worktree-names.ts b/mobile/src/worktree/use-retired-worktree-names.ts index 13252c639c3..3158110f82c 100644 --- a/mobile/src/worktree/use-retired-worktree-names.ts +++ b/mobile/src/worktree/use-retired-worktree-names.ts @@ -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`,