diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx
index 40d1ef0..9dd2393 100644
--- a/src/renderer/src/App.test.tsx
+++ b/src/renderer/src/App.test.tsx
@@ -2071,7 +2071,9 @@ describe('App', () => {
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析这个问题' }
})
- fireEvent.click(await screen.findByLabelText('发送'))
+ const send = await screen.findByLabelText('发送')
+ await waitFor(() => expect(send).toBeEnabled())
+ fireEvent.click(send)
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 9c8d4b3..006c50e 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -38,7 +38,6 @@ import {
} from 'lucide-react'
import {
Component,
- lazy,
Suspense,
useCallback,
useDeferredValue,
@@ -165,38 +164,36 @@ import type {
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
import { scheduleIdleRoutePreload } from './idle-route-preload'
+import { createPreloadableComponent } from './preloadable-component'
import { formatTime } from './time-format'
-const loadKnowledgeWorkspace = () => import('./KnowledgeWorkspace')
-const loadHeartbeatCenter = () => import('./HeartbeatCenter')
-const loadMagicNotesWorkspace = () => import('./MagicNotesWorkspace')
-const loadSettingsPanel = () => import('./SettingsPanel')
+const knowledgeWorkspaceRoute = createPreloadableComponent(
+ () => import('./KnowledgeWorkspace'),
+ (module) => module.KnowledgeWorkspace
+)
+const heartbeatCenterRoute = createPreloadableComponent(
+ () => import('./HeartbeatCenter'),
+ (module) => module.HeartbeatCenter
+)
+const magicNotesWorkspaceRoute = createPreloadableComponent(
+ () => import('./MagicNotesWorkspace'),
+ (module) => module.MagicNotesWorkspace
+)
+const settingsPanelRoute = createPreloadableComponent(
+ () => import('./SettingsPanel'),
+ (module) => module.SettingsPanel
+)
const idleRouteModuleLoaders = [
- loadKnowledgeWorkspace,
- loadHeartbeatCenter,
- loadMagicNotesWorkspace,
- loadSettingsPanel
+ knowledgeWorkspaceRoute.preload,
+ heartbeatCenterRoute.preload,
+ magicNotesWorkspaceRoute.preload,
+ settingsPanelRoute.preload
] as const
-const KnowledgeWorkspace = lazy(async () => {
- const module = await loadKnowledgeWorkspace()
- return { default: module.KnowledgeWorkspace }
-})
-
-const HeartbeatCenter = lazy(async () => {
- const module = await loadHeartbeatCenter()
- return { default: module.HeartbeatCenter }
-})
-
-const MagicNotesWorkspace = lazy(async () => {
- const module = await loadMagicNotesWorkspace()
- return { default: module.MagicNotesWorkspace }
-})
-
-const SettingsPanel = lazy(async () => {
- const module = await loadSettingsPanel()
- return { default: module.SettingsPanel }
-})
+const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component
+const HeartbeatCenter = heartbeatCenterRoute.Component
+const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component
+const SettingsPanel = settingsPanelRoute.Component
const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500
diff --git a/src/renderer/src/preloadable-component.test.tsx b/src/renderer/src/preloadable-component.test.tsx
new file mode 100644
index 0000000..a78ffe1
--- /dev/null
+++ b/src/renderer/src/preloadable-component.test.tsx
@@ -0,0 +1,155 @@
+import {
+ act,
+ cleanup,
+ render,
+ screen
+} from '@testing-library/react'
+import { Suspense } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createPreloadableComponent } from './preloadable-component'
+
+function deferred(): {
+ promise: Promise
+ resolve: (value: T) => void
+ reject: (reason: unknown) => void
+} {
+ let resolve!: (value: T) => void
+ let reject!: (reason: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+
+describe('createPreloadableComponent', () => {
+ it('renders synchronously without a fallback after preloading', async () => {
+ const fallbackRender = vi.fn()
+ const Loaded = ({ label }: { label: string }) => {label}
+ const loadModule = vi.fn(async () => ({ Loaded }))
+ const route = createPreloadableComponent(
+ loadModule,
+ (module) => module.Loaded
+ )
+ const Component = route.Component
+ const Fallback = (): React.JSX.Element => {
+ fallbackRender()
+ return loading
+ }
+
+ await route.preload()
+ render(
+ }>
+
+
+ )
+
+ expect(screen.getByRole('heading', { name: 'ready' }))
+ .toBeInTheDocument()
+ expect(fallbackRender).not.toHaveBeenCalled()
+ expect(loadModule).toHaveBeenCalledOnce()
+ })
+
+ it('shares an unfinished preload with the lazy fallback path', async () => {
+ const module = deferred<{
+ Loaded: React.ComponentType<{ label: string }>
+ }>()
+ const loadModule = vi.fn(() => module.promise)
+ const route = createPreloadableComponent(
+ loadModule,
+ (loaded) => loaded.Loaded
+ )
+ const Component = route.Component
+ const preload = route.preload()
+
+ render(
+ loading
}>
+
+
+ )
+
+ expect(await screen.findByText('loading')).toBeInTheDocument()
+ await act(async () => {
+ module.resolve({
+ Loaded: ({ label }) => {label}
+ })
+ await preload
+ })
+
+ expect(
+ await screen.findByRole('heading', { name: 'ready' })
+ ).toBeInTheDocument()
+ expect(loadModule).toHaveBeenCalledOnce()
+ })
+
+ it('allows a first render to retry a failed idle preload', async () => {
+ let attempts = 0
+ const Loaded = ({ label }: { label: string }) => {label}
+ const route = createPreloadableComponent(
+ async () => {
+ attempts += 1
+ if (attempts === 1) {
+ throw new Error('temporary failure')
+ }
+ return { Loaded }
+ },
+ (module) => module.Loaded
+ )
+ const Component = route.Component
+
+ await expect(route.preload()).rejects.toThrow('temporary failure')
+ render(
+ loading}>
+
+
+ )
+
+ expect(
+ await screen.findByRole('heading', { name: 'recovered' })
+ ).toBeInTheDocument()
+ expect(attempts).toBe(2)
+ })
+
+ it('retries when an in-flight preload shared with the first render fails', async () => {
+ type RouteModule = {
+ Loaded: React.ComponentType<{ label: string }>
+ }
+ const firstModule = deferred()
+ const Loaded = ({ label }: { label: string }) => {label}
+ let attempts = 0
+ const loadModule = vi.fn((): Promise => {
+ attempts += 1
+ return attempts === 1
+ ? firstModule.promise
+ : Promise.resolve({ Loaded })
+ })
+ const route = createPreloadableComponent(
+ loadModule,
+ (module) => module.Loaded
+ )
+ const Component = route.Component
+ const preload = route.preload()
+
+ render(
+ loading}>
+
+
+ )
+
+ expect(await screen.findByText('loading')).toBeInTheDocument()
+ await act(async () => {
+ firstModule.reject(new Error('temporary shared failure'))
+ await expect(preload).rejects.toThrow('temporary shared failure')
+ })
+
+ expect(
+ await screen.findByRole('heading', { name: 'recovered shared route' })
+ ).toBeInTheDocument()
+ expect(loadModule).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/renderer/src/preloadable-component.tsx b/src/renderer/src/preloadable-component.tsx
new file mode 100644
index 0000000..d21a85b
--- /dev/null
+++ b/src/renderer/src/preloadable-component.tsx
@@ -0,0 +1,69 @@
+import {
+ createElement,
+ lazy,
+ useRef,
+ type ComponentType
+} from 'react'
+
+export type PreloadableComponent = {
+ Component: ComponentType
+ preload: () => Promise
+}
+
+export function createPreloadableComponent<
+ Module,
+ Props extends object
+>(
+ loadModule: () => Promise,
+ selectComponent: (module: Module) => ComponentType
+): PreloadableComponent {
+ let loadPromise: Promise | undefined
+ let resolvedComponent: ComponentType | undefined
+
+ const preload = (): Promise => {
+ if (!loadPromise) {
+ loadPromise = Promise.resolve()
+ .then(loadModule)
+ .then((module) => {
+ resolvedComponent = selectComponent(module)
+ return module
+ })
+ .catch((error: unknown) => {
+ loadPromise = undefined
+ throw error
+ })
+ }
+ return loadPromise
+ }
+
+ const loadLazyModule = async () => {
+ const module = await preload()
+ return {
+ default: resolvedComponent ?? selectComponent(module)
+ }
+ }
+
+ const LazyComponent = lazy(async () => {
+ try {
+ return await loadLazyModule()
+ } catch {
+ return loadLazyModule()
+ }
+ })
+
+ function PreloadedComponent(props: Props): React.JSX.Element {
+ const renderSynchronously = useRef(
+ resolvedComponent !== undefined
+ ).current
+ const Component = resolvedComponent
+ if (renderSynchronously && Component) {
+ return createElement(Component, props)
+ }
+ return createElement(LazyComponent, props)
+ }
+
+ return {
+ Component: PreloadedComponent,
+ preload
+ }
+}