fix: render preloaded routes synchronously

This commit is contained in:
mesalogo
2026-08-15 18:06:04 +08:00
parent 79d3d73038
commit faa4456661
4 changed files with 252 additions and 29 deletions
+3 -1
View File
@@ -2071,7 +2071,9 @@ describe('App', () => {
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析这个问题' } 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()) await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0] const request = run.mock.calls[0]?.[0]
if (!request) { if (!request) {
+25 -28
View File
@@ -38,7 +38,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
Component, Component,
lazy,
Suspense, Suspense,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
@@ -165,38 +164,36 @@ import type {
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog' import { ReleaseNotesDialog } from './ReleaseNotesDialog'
import { scheduleIdleRoutePreload } from './idle-route-preload' import { scheduleIdleRoutePreload } from './idle-route-preload'
import { createPreloadableComponent } from './preloadable-component'
import { formatTime } from './time-format' import { formatTime } from './time-format'
const loadKnowledgeWorkspace = () => import('./KnowledgeWorkspace') const knowledgeWorkspaceRoute = createPreloadableComponent(
const loadHeartbeatCenter = () => import('./HeartbeatCenter') () => import('./KnowledgeWorkspace'),
const loadMagicNotesWorkspace = () => import('./MagicNotesWorkspace') (module) => module.KnowledgeWorkspace
const loadSettingsPanel = () => import('./SettingsPanel') )
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 = [ const idleRouteModuleLoaders = [
loadKnowledgeWorkspace, knowledgeWorkspaceRoute.preload,
loadHeartbeatCenter, heartbeatCenterRoute.preload,
loadMagicNotesWorkspace, magicNotesWorkspaceRoute.preload,
loadSettingsPanel settingsPanelRoute.preload
] as const ] as const
const KnowledgeWorkspace = lazy(async () => { const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component
const module = await loadKnowledgeWorkspace() const HeartbeatCenter = heartbeatCenterRoute.Component
return { default: module.KnowledgeWorkspace } const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component
}) const SettingsPanel = settingsPanelRoute.Component
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 messageRenderBatchSize = 80 const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500 const conversationPersistenceIntervalMs = 500
@@ -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<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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 }) => <h1>{label}</h1>
const loadModule = vi.fn(async () => ({ Loaded }))
const route = createPreloadableComponent(
loadModule,
(module) => module.Loaded
)
const Component = route.Component
const Fallback = (): React.JSX.Element => {
fallbackRender()
return <p>loading</p>
}
await route.preload()
render(
<Suspense fallback={<Fallback />}>
<Component label="ready" />
</Suspense>
)
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(
<Suspense fallback={<p>loading</p>}>
<Component label="ready" />
</Suspense>
)
expect(await screen.findByText('loading')).toBeInTheDocument()
await act(async () => {
module.resolve({
Loaded: ({ label }) => <h1>{label}</h1>
})
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 }) => <h1>{label}</h1>
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(
<Suspense fallback={<p>loading</p>}>
<Component label="recovered" />
</Suspense>
)
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<RouteModule>()
const Loaded = ({ label }: { label: string }) => <h1>{label}</h1>
let attempts = 0
const loadModule = vi.fn((): Promise<RouteModule> => {
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(
<Suspense fallback={<p>loading</p>}>
<Component label="recovered shared route" />
</Suspense>
)
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)
})
})
@@ -0,0 +1,69 @@
import {
createElement,
lazy,
useRef,
type ComponentType
} from 'react'
export type PreloadableComponent<Props extends object> = {
Component: ComponentType<Props>
preload: () => Promise<unknown>
}
export function createPreloadableComponent<
Module,
Props extends object
>(
loadModule: () => Promise<Module>,
selectComponent: (module: Module) => ComponentType<Props>
): PreloadableComponent<Props> {
let loadPromise: Promise<Module> | undefined
let resolvedComponent: ComponentType<Props> | undefined
const preload = (): Promise<Module> => {
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
}
}