fix: defer remote visibility defaults after startup

This commit is contained in:
Brennan Benson
2026-08-14 19:17:57 -07:00
parent 7558fb064a
commit dccef512bb
5 changed files with 155 additions and 23 deletions
@@ -58,6 +58,8 @@ function useStartupActions() {
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchOrcaProfiles: s.fetchOrcaProfiles,
fetchSettings: s.fetchSettings,
awaitOwnerWorktreeVisibilityDefaultsHydration:
s.awaitOwnerWorktreeVisibilityDefaultsHydration,
fetchKeybindings: s.fetchKeybindings,
initGitHubCache: s.initGitHubCache,
hydrateWorkspaceSession: s.hydrateWorkspaceSession,
@@ -109,8 +111,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
try {
// Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings.
void actions.fetchOrcaProfiles()
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state.
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why: publish local settings before persisted UI/catalog work; a saved remote owner's defaults can spend the full connect timeout.
await timeRendererStartupStep('fetch-settings', () =>
actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true })
)
// Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data.
publishTerminalViewAttributesAtAppStart(
useAppStore.getState().settings,
@@ -283,6 +287,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
void (async () => {
try {
try {
// Why: remote rows must not render under a fallback visibility while their owner default is still loading.
await timeRendererStartupStep('owner-visibility-defaults', () =>
actions.awaitOwnerWorktreeVisibilityDefaultsHydration()
)
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
+11 -1
View File
@@ -30,7 +30,9 @@ describe('renderer startup runtime routing', () => {
const relativeIndex = startupBlock.indexOf(needle)
return relativeIndex === -1 ? -1 : startupBlockStart + relativeIndex
}
const settingsIndex = indexInStartupBlock('actions.fetchSettings()')
const settingsIndex = indexInStartupBlock(
'actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true })'
)
const uiGetIndex = indexInStartupBlock("timeRendererStartupStep('ui-get'")
const hydrateUiIndex = indexInStartupBlock("timeRendererStartupSyncStep('hydrate-persisted-ui'")
const localReposIndex = indexInStartupBlock(
@@ -96,6 +98,14 @@ describe('renderer startup runtime routing', () => {
expect(fullWorktreesIndex).toBeGreaterThan(
source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'")
)
const ownerDefaultsIndex = source.indexOf(
'actions.awaitOwnerWorktreeVisibilityDefaultsHydration()'
)
const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'")
expect(ownerDefaultsIndex).toBeGreaterThan(
source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'")
)
expect(ownerDefaultsIndex).toBeLessThan(remoteCatalogIndex)
// Why: the deferred full scan must be followed by a re-prune so deleted-worktree visit
// timestamps for non-session repos are dropped once every repo is authoritative.
expect(
@@ -0,0 +1,65 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { hydrateOwnerWorktreeVisibilityDefaults } from './worktree-visibility-owner-settings'
export type SettingsStateSetter = Parameters<StateCreator<AppState, [], []>>[0]
type SettingsStateGetter = Parameters<StateCreator<AppState, [], []>>[1]
const completedOwnerVisibilityDefaultsHydration = Promise.resolve()
const ownerVisibilityDefaultsHydrationByStore = new WeakMap<SettingsStateGetter, Promise<void>>()
export type FetchSettingsOptions = {
deferOwnerWorktreeVisibilityDefaults?: boolean
}
export function startOwnerWorktreeVisibilityDefaultsHydration(args: {
settings: GlobalSettings
deferPublication: boolean
shouldPublish: () => boolean
set: SettingsStateSetter
get: SettingsStateGetter
}): Promise<void> {
if (args.deferPublication) {
args.set((state) => ({
settings: args.settings,
worktreeVisibilityDefaultsByHost: args.settings.worktreeVisibilityDefaults
? {
...state.worktreeVisibilityDefaultsByHost,
[LOCAL_EXECUTION_HOST_ID]: args.settings.worktreeVisibilityDefaults
}
: state.worktreeVisibilityDefaultsByHost
}))
}
const hydration = hydrateOwnerWorktreeVisibilityDefaults(
args.settings,
args.get().worktreeVisibilityDefaultsByHost
)
.then((hydrated) => {
if (!args.shouldPublish()) {
return
}
args.set((state) => ({
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: {
...state.worktreeVisibilityDefaultsByHost,
...hydrated.defaultsByHost
},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId,
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId:
hydrated.sourceDefaultsSupportedRuntimeEnvironmentId
}))
})
.catch((err) => console.error('Failed to fetch settings:', err))
ownerVisibilityDefaultsHydrationByStore.set(args.get, hydration)
return hydration
}
export function awaitOwnerWorktreeVisibilityDefaultsHydration(
get: SettingsStateGetter
): Promise<void> {
return (
ownerVisibilityDefaultsHydrationByStore.get(get) ?? completedOwnerVisibilityDefaultsHydration
)
}
@@ -35,7 +35,8 @@ it('preserves host defaults added while owner hydration is in flight', async ()
get: vi.fn().mockResolvedValue({ activeRuntimeEnvironmentId: 'env-1' })
},
runtimeEnvironments: {
call: vi.fn().mockReturnValue(ownerRead)
call: vi.fn().mockReturnValue(ownerRead),
list: vi.fn().mockResolvedValue([])
}
}
})
@@ -58,3 +59,50 @@ it('preserves host defaults added while owner hydration is in flight', async ()
'runtime:env-2': { external: 'show' }
})
})
it('publishes startup settings before remote owner hydration and local catalog work', async () => {
markRuntimeEnvironmentCompatible('env-1')
let resolveOwnerRead!: (value: unknown) => void
const ownerRead = new Promise((resolve) => (resolveOwnerRead = resolve))
vi.stubGlobal('window', {
api: {
settings: {
get: vi.fn().mockResolvedValue({
activeRuntimeEnvironmentId: 'env-1',
worktreeVisibilityDefaults: { external: 'show' }
}),
set: vi.fn().mockResolvedValue({
activeRuntimeEnvironmentId: 'env-1',
pluginSystemEnabled: true,
worktreeVisibilityDefaults: { external: 'show' }
})
},
runtimeEnvironments: {
call: vi.fn().mockReturnValue(ownerRead),
list: vi.fn().mockResolvedValue([])
}
}
})
const store = createTestStore()
let localCatalogStarted = false
const startup = (async () => {
await store.getState().fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true })
localCatalogStarted = true
})()
await vi.waitFor(() => expect(window.api.runtimeEnvironments.call).toHaveBeenCalled())
expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1')
expect(store.getState().worktreeVisibilityDefaultsByHost.local).toEqual({ external: 'show' })
expect(localCatalogStarted).toBe(true)
await store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true })
resolveOwnerRead({
ok: true,
result: { settings: { worktreeVisibilityDefaults: { external: 'hide' } } },
_meta: { runtimeId: 'runtime-1' }
})
await startup
await store.getState().awaitOwnerWorktreeVisibilityDefaultsHydration()
expect(store.getState().settings?.pluginSystemEnabled).toBe(true)
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeUndefined()
})
+20 -19
View File
@@ -32,6 +32,7 @@ import {
hydrateOwnerWorktreeVisibilityDefaults,
type WorktreeVisibilityDefaultsByHost
} from './worktree-visibility-owner-settings'
import * as ownerHydration from './settings-owner-hydration-publication'
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host'
@@ -40,7 +41,8 @@ export type SettingsSlice = SettingsSearchState & {
worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: string | null
fetchSettings: () => Promise<void>
fetchSettings: (options?: ownerHydration.FetchSettingsOptions) => Promise<void>
awaitOwnerWorktreeVisibilityDefaultsHydration: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
updateSettingsOrThrow: (updates: Partial<GlobalSettings>) => Promise<void>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
@@ -50,7 +52,6 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
terminalScrollbackBytes?: unknown
}
type SettingsStateSetter = Parameters<StateCreator<AppState, [], [], SettingsSlice>>[0]
let ownerSettingsHydrationGeneration = 0
function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null {
@@ -134,7 +135,7 @@ function normalizeSettingsUpdates(
}
async function persistSettingsUpdates(
set: SettingsStateSetter,
set: ownerHydration.SettingsStateSetter,
updates: Partial<GlobalSettings>,
currentSettings: GlobalSettings | null,
supportedRuntimeEnvironmentId: string | null,
@@ -185,27 +186,24 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null,
...createSettingsSearchState((state) => set(state)),
fetchSettings: async () => {
fetchSettings: async (options) => {
const generation = ++ownerSettingsHydrationGeneration
try {
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
(await window.api.settings.get()) as GlobalSettings,
get().worktreeVisibilityDefaultsByHost
)
const localSettings = (await window.api.settings.get()) as GlobalSettings
if (generation !== ownerSettingsHydrationGeneration) {
return
}
set((state) => ({
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: {
...state.worktreeVisibilityDefaultsByHost,
...hydrated.defaultsByHost
},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId,
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId:
hydrated.sourceDefaultsSupportedRuntimeEnvironmentId
}))
const ownerVisibilityDefaultsHydration =
ownerHydration.startOwnerWorktreeVisibilityDefaultsHydration({
settings: localSettings,
deferPublication: options?.deferOwnerWorktreeVisibilityDefaults === true,
shouldPublish: () => generation === ownerSettingsHydrationGeneration,
set,
get
})
if (!options?.deferOwnerWorktreeVisibilityDefaults) {
await ownerVisibilityDefaultsHydration
}
} catch (err) {
console.error('Failed to fetch settings:', err)
}
@@ -223,6 +221,9 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
}
},
awaitOwnerWorktreeVisibilityDefaultsHydration: () =>
ownerHydration.awaitOwnerWorktreeVisibilityDefaultsHydration(get),
updateSettings: async (updates) => {
const generation = ++ownerSettingsHydrationGeneration
const visibilityOwnerHostId = getSettingsFocusedExecutionHostId(get().settings)