From faa44566612cd7d7bc60b0c09c20417bba4467f9 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 15 Aug 2026 18:01:07 +0800 Subject: [PATCH] fix: render preloaded routes synchronously --- src/renderer/src/App.test.tsx | 4 +- src/renderer/src/App.tsx | 53 +++--- .../src/preloadable-component.test.tsx | 155 ++++++++++++++++++ src/renderer/src/preloadable-component.tsx | 69 ++++++++ 4 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/preloadable-component.test.tsx create mode 100644 src/renderer/src/preloadable-component.tsx 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 + } +}