mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): avoid unsupported Hermes array sorting (#16506)
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user