fix(desktop): setup cards stop redrawing in a loop

useSetupRows built a new row array on every render for the app-owned
lists (accent, layout, theme). That recreated the card's stage callback,
whose effect wrote $setupChooseStages, which re-rendered the card. The
rows are memoized on their inputs now, and stageSetupChoose skips the
atom write when a patch changes nothing.
This commit is contained in:
alt-glitch
2026-10-07 19:22:43 +05:30
parent 17646902e1
commit 40512011b6
3 changed files with 39 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import type { ConnectorRow, SetupChooseKind, SetupChooseOption } from '@hermes/shared'
import { useMemo } from 'react'
import { MODE_OPTIONS } from '@/app/settings/constants'
import { $chatLayoutPicked, assembleChatOnboarding, snapshotChatLayout } from '@/components/onboarding-chat/assembly'
@@ -132,15 +133,20 @@ export function useSetupRows(setup: null | SetupChooseSpec, storedId: null | str
const connectors = useConnectorRows(setup?.options === null && setup.kind === 'connectors' ? storedId : null)
const plugins = useOnboardingPluginList(setup?.options === null && setup.kind === 'plugins' ? storedId : null)
if (!setup) {
return []
}
// Same inputs must return the same array: the pending card's stage callback and store write key off it.
return useMemo(() => {
if (!setup) {
return NO_ROWS
}
const rows = setup.options ?? APP_ROWS[setup.kind]({ connectors, dark: renderedMode === 'dark', plugins, t })
const rows = setup.options ?? APP_ROWS[setup.kind]({ connectors, dark: renderedMode === 'dark', plugins, t })
return setup.kind === 'theme' && rows ? rows.filter(row => THEME_TILES.includes(row.id)) : rows
return setup.kind === 'theme' && rows ? rows.filter(row => THEME_TILES.includes(row.id)) : rows
}, [connectors, plugins, renderedMode, setup, t])
}
const NO_ROWS: SetupRow[] = []
export function useSetupLabel(
kind: SetupChooseKind,
options: null | SetupRow[],

View File

@@ -181,6 +181,21 @@ describe('answerSetupCard', () => {
expect(hasClarifyRequest('session-a')).toBe(false)
expect(look).toBe('#0000ff')
})
it('does not notify subscribers when a staged patch changes nothing', () => {
const preview = () => undefined
const listener = vi.fn()
stageSetupChoose('req-a', { labels: ACCENTS, preview })
const before = $setupChooseStages.get()
const unlisten = $setupChooseStages.listen(listener)
stageSetupChoose('req-a', { labels: ACCENTS, preview })
unlisten()
expect($setupChooseStages.get()).toBe(before)
expect(listener).not.toHaveBeenCalled()
})
})
describe('normalizeChoices', () => {

View File

@@ -283,7 +283,19 @@ export const setupChooseStage = (requestId: string): SetupChooseStage =>
$setupChooseStages.get()[requestId] ?? EMPTY_SETUP_STAGE
export function stageSetupChoose(requestId: string, patch: Partial<SetupChooseStage>): void {
$setupChooseStages.set({ ...$setupChooseStages.get(), [requestId]: { ...setupChooseStage(requestId), ...patch } })
const current = setupChooseStage(requestId)
// A no-op patch keeps the atom reference so subscribers do not rerender.
const unchanged = Object.entries(patch).every(([key, value]) =>
// SAFETY: the entries of a Partial<SetupChooseStage> carry only SetupChooseStage keys.
Object.is(current[key as keyof SetupChooseStage], value)
)
if (unchanged && requestId in $setupChooseStages.get()) {
return
}
$setupChooseStages.set({ ...$setupChooseStages.get(), [requestId]: { ...current, ...patch } })
}
export function commitSetupChoose(requestId: string): void {