fix: render preloaded routes synchronously
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user