fix: render preloaded routes synchronously
This commit is contained in:
@@ -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) {
|
||||
|
||||
+25
-28
@@ -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
|
||||
|
||||
@@ -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