diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 983c571f4aa..042b10565f0 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,6 +18,7 @@ import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility import { useAppStore } from '../store' import type { UpdateStatus } from '../../../shared/update-status-types' import { useLazyModalMounts } from './use-lazy-modal-mounts' +import { usePreloadWorktreeJumpPalette, WorktreeJumpPalette } from './worktree-jump-palette-preload' import { selectAppRootSurfacePetEnabled, selectAppRootSurfaceTelemetryOptedIn, @@ -27,7 +28,6 @@ import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel import type { OnboardingGate } from './use-onboarding-and-feature-tips' const QuickOpen = lazy(() => import('../components/QuickOpen')) -const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette')) const WorkspaceCleanupDialog = lazy( () => import('../components/workspace-cleanup/WorkspaceCleanupDialog') ) @@ -137,6 +137,7 @@ export function AppRootSurfaces(props: { }): React.JSX.Element { const { floatingWorkspace, onboardingGate } = props const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts() + usePreloadWorktreeJumpPalette() const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) // Keep this always-mounted surface subscribed only to the settings fields it reads. A diff --git a/src/renderer/src/app-shell/worktree-jump-palette-preload.ts b/src/renderer/src/app-shell/worktree-jump-palette-preload.ts new file mode 100644 index 00000000000..846be9b5dd6 --- /dev/null +++ b/src/renderer/src/app-shell/worktree-jump-palette-preload.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react' +import { scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { lazyWithPreload } from '@/lib/lazy-with-retry' + +export const WorktreeJumpPalette = lazyWithPreload( + () => import('../components/WorktreeJumpPalette') +) + +/** Loads the Cmd+J palette after startup settles so its first open renders without suspending. */ +export function usePreloadWorktreeJumpPalette(): void { + useEffect( + () => + scheduleAfterInputQuiet(WorktreeJumpPalette.preload, { + delayMs: 500, + quietMs: 300, + idleTimeoutMs: 1_000, + maxWaitMs: 2_000 + }), + [] + ) +} diff --git a/src/renderer/src/lib/lazy-with-retry.preload.test.tsx b/src/renderer/src/lib/lazy-with-retry.preload.test.tsx new file mode 100644 index 00000000000..f76223cacb1 --- /dev/null +++ b/src/renderer/src/lib/lazy-with-retry.preload.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { act, Suspense } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +import { lazyWithPreload } from './lazy-with-retry' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +function Palette(): React.JSX.Element { + return +} + +describe('lazyWithPreload', () => { + it('renders a preloaded component in the same commit, without the Suspense fallback', async () => { + const factory = vi.fn(() => Promise.resolve({ default: Palette })) + const Preloadable = lazyWithPreload(factory) + Preloadable.preload() + await act(async () => {}) + + const container = document.createElement('div') + const root = createRoot(container) + // A synchronous act flushes one render with no chance to retry a suspended one. + act(() => { + root.render( + loading}> + + + ) + }) + + expect(container.querySelector('input[aria-label="palette"]')).not.toBeNull() + expect(container.textContent).not.toContain('loading') + expect(factory).toHaveBeenCalledTimes(1) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 77650b67ded..a19ad4063a8 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -1,4 +1,12 @@ -import { lazy, type ComponentType, type LazyExoticComponent } from 'react' +import { + createElement, + lazy, + useState, + type ComponentProps, + type ComponentType, + type LazyExoticComponent, + type ReactNode +} from 'react' import { requestLazyChunkRecoveryReload, @@ -256,3 +264,45 @@ export function lazyWithRetry( ): LazyExoticComponent { return lazy(() => loadLazyWithRetry(factory, options)) } + +export type PreloadableLazyComponent = ComponentType> & { + /** Loads the chunk ahead of first render; failures stay silent so the real render owns recovery. */ + preload: () => void +} + +/** + * lazyWithRetry plus preload(). Once preloaded, the component renders the loaded module + * directly instead of through React.lazy, so its first render never suspends: React holds + * a newly resolved Suspense reveal for up to 300 ms, which is what made first opens slow. + */ +export function lazyWithPreload( + factory: LazyFactory, + options?: LazyWithRetryOptions +): PreloadableLazyComponent { + let loaded: T | null = null + const LazyComponent = lazyWithRetry( + () => + factory().then((module) => { + loaded ??= module.default + return module + }), + options + ) + function PreloadableComponent(props: ComponentProps): ReactNode { + // Why: pinned per mount so a module resolving mid-life cannot swap the element type and remount the subtree. + const [Component] = useState(() => loaded ?? LazyComponent) + return createElement(Component, props) + } + PreloadableComponent.preload = (): void => { + if (loaded) { + return + } + factory().then( + (module) => { + loaded ??= module.default + }, + () => undefined + ) + } + return PreloadableComponent +}