fix(store): keep setup-script dismissal array identity on no-op repo fetches

* fix(store): keep setup-script dismissal array identity on no-op repo fetches

filterSetupScriptPromptDismissalsToValidRepos always allocated a new
array, so fetchRepos, fetchRuntimeEnvironmentRepos, and
validateRepoScopedUi replaced setupScriptPromptDismissedRepoIds even
when every entry was already a valid host-identity key.
SetupScriptPromptCard Object.is-subscribes to that array, so a no-op
catalog refresh missed 100% of the time.

Return the original store array when the filtered result is
element-wise identical to the input. Allocate only when an entry is
dropped, rewritten from a legacy generation-v1:repoId key, or deduped.
Call sites already assign the helper result.

Co-authored-by: Orca <help@stably.ai>

* type: make setupScriptPromptDismissedRepoIds readonly

Return readonly string[] from filterSetupScriptPromptDismissalsToValidRepos
and sanitizeSetupScriptPromptDismissals, and update the store field type to
readonly. This prevents accidental mutation of the live store array on no-op
prune cycles. Add isUnchangedDismissalList type predicate for identity check.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-13 00:05:56 -07:00
committed by GitHub
co-authored by Orca
parent c61f29639c
commit bc2df22328
4 changed files with 95 additions and 3 deletions
@@ -202,6 +202,59 @@ describe('setup script prompt inspection', () => {
).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
})
it('reuses the input array when it is empty', () => {
const input: string[] = []
expect(
filterSetupScriptPromptDismissalsToValidRepos(
input,
new Set([getRepoHostIdentityForParts('repo-1', 'local')])
)
).toBe(input)
})
it('reuses the input array when every dismissal is already a valid host-identity key', () => {
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
const remoteIdentity = getRepoHostIdentityForParts('repo-2', 'ssh:host-a')
const input = [
getSetupScriptPromptDismissalKey(localIdentity),
getSetupScriptPromptDismissalKey(remoteIdentity)
]
expect(
filterSetupScriptPromptDismissalsToValidRepos(
input,
new Set([localIdentity, remoteIdentity])
)
).toBe(input)
})
it('allocates when a legacy repo-id dismissal is rewritten to a host identity', () => {
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
const input = [getSetupScriptPromptDismissalKey('repo-1')]
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
expect(result).not.toBe(input)
expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
})
it('allocates when a stale dismissal is dropped', () => {
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
const input = [
getSetupScriptPromptDismissalKey(localIdentity),
getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts('gone', 'local'))
]
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
expect(result).not.toBe(input)
expect(result).toEqual([getSetupScriptPromptDismissalKey(localIdentity)])
})
it('allocates when a duplicate valid dismissal is deduped', () => {
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
const key = getSetupScriptPromptDismissalKey(localIdentity)
const input = [key, key]
const result = filterSetupScriptPromptDismissalsToValidRepos(input, new Set([localIdentity]))
expect(result).not.toBe(input)
expect(result).toEqual([key])
})
it('drops a legacy repo-id dismissal when that id exists on multiple hosts', () => {
const localIdentity = getRepoHostIdentityForParts('repo-1', 'local')
const remoteIdentity = getRepoHostIdentityForParts('repo-1', 'runtime:windows')
+21 -2
View File
@@ -90,10 +90,21 @@ export function isSetupScriptPromptDismissed(
return dismissedEntries.includes(getSetupScriptPromptDismissalKey(repoHostIdentity))
}
function isUnchangedDismissalList(
value: unknown,
next: readonly string[]
): value is readonly string[] {
return (
Array.isArray(value) &&
value.length === next.length &&
value.every((entry, index) => entry === next[index])
)
}
export function filterSetupScriptPromptDismissalsToValidRepos(
value: unknown,
validRepoHostIdentities: Set<string>
): string[] {
): readonly string[] {
const unambiguousIdentityByRepoId = new Map<string, string | null>()
for (const identity of validRepoHostIdentities) {
const separatorIndex = identity.indexOf('\0')
@@ -117,10 +128,18 @@ export function filterSetupScriptPromptDismissalsToValidRepos(
}
}
}
// Why: fetchRepos / fetchRuntimeEnvironmentRepos / validateRepoScopedUi assign
// this into set() on every catalog refresh. SetupScriptPromptCard Object.is-
// subscribes to the array, so a fresh copy on a no-op prune is a guaranteed miss.
// Compare against the original input, not the sanitize copy — sanitize always
// allocates, including for [] and already-valid host-identity keys.
if (isUnchangedDismissalList(value, next)) {
return value
}
return next
}
export function sanitizeSetupScriptPromptDismissals(value: unknown): string[] {
export function sanitizeSetupScriptPromptDismissals(value: unknown): readonly string[] {
if (!Array.isArray(value)) {
return []
}
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Project, Repo } from '../../../../shared/types'
import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt'
import { getRepoHostIdentityForParts } from './repo-host-identity'
import { createTestStore } from './store-test-helpers'
// Why: every field here is load-bearing. A scalar-only repo reconciles even when the structural
@@ -281,3 +283,21 @@ describe('repo filter identity across catalog refreshes', () => {
expect(store.getState().filterRepoIds).toBe(first)
})
})
describe('setup-script dismissal identity across catalog refreshes', () => {
it('keeps the dismissal array when a refetch prunes nothing', async () => {
const store = createTestStore()
store.setState({
setupScriptPromptDismissedRepoIds: [
getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts(repo.id, 'local'))
]
})
const first = store.getState().setupScriptPromptDismissedRepoIds
await store.getState().fetchRepos()
// Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op
// catalog refresh must not allocate just because the helper rebuilt next.
expect(store.getState().setupScriptPromptDismissedRepoIds).toBe(first)
})
})
+1 -1
View File
@@ -853,7 +853,7 @@ export type UISlice = {
) => void
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
clearOrcaHookTrustForRepo: (repoId: string) => void
setupScriptPromptDismissedRepoIds: string[]
setupScriptPromptDismissedRepoIds: readonly string[]
dismissSetupScriptPrompt: (repoHostIdentity: string) => void
setupGuideSidebarDismissed: boolean
setSetupGuideSidebarDismissed: (dismissed: boolean) => void