diff --git a/package-lock.json b/package-lock.json index f390f74..e7076cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.3.2", + "version": "1.3.3", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index 3237f14..a61b80e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.3.2", + "version": "1.3.3", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index a0ea6d2..1130b38 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -42,6 +42,7 @@ interface AppShellProps { onSubmitRemoteUrl: (url: string) => void | Promise; remoteSubmitLoading?: boolean; onSelectSample: (sample: SampleDataset) => void | Promise; + onCancelLoading?: () => void; historyItems: DatasetHistoryListItem[]; onReplayHistory: (id: string) => void | Promise; onDropRosRecordingFiles: (files: File[], items?: DataTransferItemList) => void | Promise; @@ -91,6 +92,7 @@ export const AppShell: React.FC = ({ onSubmitRemoteUrl, remoteSubmitLoading, onSelectSample, + onCancelLoading, historyItems, onReplayHistory, onDropRosRecordingFiles, @@ -175,7 +177,7 @@ export const AppShell: React.FC = ({ onSubmitRemoteUrl={onSubmitRemoteUrl} remoteSubmitLoading={remoteSubmitLoading} onSelectSample={onSelectSample} - onRequestChangeRemoteUrl={onOpenRemotePrompt} + onCancelLoading={onCancelLoading} historyItems={historyItems} onReplayHistory={onReplayHistory} onDropRosRecordingFiles={onDropRosRecordingFiles} diff --git a/src/features/viewer/RosViewContent.tsx b/src/features/viewer/RosViewContent.tsx index af59561..cf7ae8e 100644 --- a/src/features/viewer/RosViewContent.tsx +++ b/src/features/viewer/RosViewContent.tsx @@ -13,12 +13,14 @@ import { import { openRawMessagesPanel } from '@/features/workspace/sidebar/topic-list/openRawMessagesPanel'; import { useIntl } from 'react-intl'; import { WelcomeScreen } from '@/features/workspace/common/WelcomeScreen'; +import { LoadingOverlay } from '@/features/workspace/common/LoadingOverlay'; import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; import type { MessagePipelineState } from '@/core/pipeline/store'; import { useMessagePipelineStore } from '@/core/pipeline/store'; import type { SampleDataset } from '@/services/sampleDatasets'; import type { DatasetHistoryListItem } from '@/shared/utils/datasetHistory'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/shared/ui/resizable'; +import { Skeleton } from '@/shared/ui/skeleton'; import type { RosViewExtension } from '@/core/extensions/types'; import { buildExtensionContext } from '@/core/extensions/buildContext'; import type { FoxgloveLayoutData } from '@/core/preferences/foxgloveLayout'; @@ -49,7 +51,7 @@ interface RosViewContentProps { onSubmitRemoteUrl: (url: string) => void | Promise; remoteSubmitLoading?: boolean; onSelectSample: (sample: SampleDataset) => void | Promise; - onRequestChangeRemoteUrl?: () => void; + onCancelLoading?: () => void; historyItems: DatasetHistoryListItem[]; onReplayHistory: (id: string) => void | Promise; onDropRosRecordingFiles: (files: File[], items?: DataTransferItemList) => void | Promise; @@ -89,7 +91,7 @@ export const RosViewContent: React.FC = ({ onSubmitRemoteUrl, remoteSubmitLoading, onSelectSample, - onRequestChangeRemoteUrl, + onCancelLoading, historyItems, onReplayHistory, onDropRosRecordingFiles, @@ -112,8 +114,8 @@ export const RosViewContent: React.FC = ({ const { formatMessage } = useIntl(); const presence = useMessagePipeline((state: MessagePipelineState) => state.playerState.presence); const sortedTopics = useMessagePipeline((state: MessagePipelineState) => state.sortedTopics); - const isLoading = presence === 'initializing'; const isReady = presence === 'ready'; + const showWelcomeFallback = !isReady && Boolean(manualOpenHint); const topicDragDepthRef = useRef(0); const [sidebarPanelPercent, setSidebarPanelPercent] = useState(() => getInitialSidebarPanelPercent(preferencePersistence), @@ -251,11 +253,9 @@ export const RosViewContent: React.FC = ({ return (
- {!isReady ? ( + {showWelcomeFallback ? (
= ({ onSubmitRemoteUrl={onSubmitRemoteUrl} remoteSubmitLoading={remoteSubmitLoading} onSelectSample={onSelectSample} - onRequestChangeRemoteUrl={onRequestChangeRemoteUrl} historyItems={historyItems} onReplayHistory={onReplayHistory} /> @@ -334,10 +333,10 @@ export const RosViewContent: React.FC = ({ className={`relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background [contain:strict] ${ isTopicDragOver ? 'ring-1 ring-inset ring-primary/40' : '' }`} - onDragEnter={handleTopicDragEnter} - onDragOver={handleMainDragOver} - onDragLeave={handleTopicDragLeave} - onDrop={handleMainDrop} + onDragEnter={isReady ? handleTopicDragEnter : undefined} + onDragOver={isReady ? handleMainDragOver : undefined} + onDragLeave={isReady ? handleTopicDragLeave : undefined} + onDrop={isReady ? handleMainDrop : undefined} > {isTopicDragOver && (
@@ -351,17 +350,30 @@ export const RosViewContent: React.FC = ({
)} - + {isReady ? ( + + ) : ( +
+ + +
+ )} + {!isReady ? ( + + ) : null} diff --git a/src/features/viewer/RosViewerImpl.tsx b/src/features/viewer/RosViewerImpl.tsx index e07ae5c..3495ab6 100644 --- a/src/features/viewer/RosViewerImpl.tsx +++ b/src/features/viewer/RosViewerImpl.tsx @@ -7,13 +7,15 @@ import { SampleDatasetDialog } from '@/features/workspace/common/SampleDatasetDi import { getArchiveUrl, getSampleDatasetsManifestUrl, loadSampleDatasets } from '@/services/sampleDatasets'; import type { SampleDataset } from '@/services/sampleDatasets'; import { extractRosFilesFromTarArchive } from '@/shared/utils/tarRosRecordings'; -import { WorkerSerializedSource } from '@/infra/workers/WorkerSerializedSource'; +import { WorkerSerializedSource, isWorkerSourceCancelledError } from '@/infra/workers/WorkerSerializedSource'; import { IterablePlayer } from '@/core/players/IterablePlayer'; import { MinimalPlayer } from '@/core/players/MinimalPlayer'; import type { Player } from '@/core/types/player'; import { useMessagePipelineStore } from '@/core/pipeline/store'; import { Navbar } from '@/features/workspace/navbar/Navbar'; import { WelcomeScreen } from '@/features/workspace/common/WelcomeScreen'; +import { LoadingOverlay } from '@/features/workspace/common/LoadingOverlay'; +import { Skeleton } from '@/shared/ui/skeleton'; import type { DatasetItem, FileListItem } from '@/shared/utils/datasetSources'; import { datasetItemsFromListItems, @@ -771,6 +773,11 @@ export const RosViewer: React.FC = (props) => { } return; } catch (err) { + if (cancelled || isWorkerSourceCancelledError(err)) { + newPlayer.close(); + createdPlayer = null; + return; + } lastErr = err; newPlayer.close(); createdPlayer = null; @@ -1166,6 +1173,7 @@ export const RosViewer: React.FC = (props) => { onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} remoteSubmitLoading={remoteUrlBusy} onSelectSample={handleSelectSample} + onCancelLoading={handleGoHome} historyItems={historyItems} onReplayHistory={(id) => void handleReplayHistory(id)} onDropRosRecordingFiles={handleDropRosRecordingFiles} @@ -1326,23 +1334,33 @@ export const RosViewer: React.FC = (props) => { recentHistoryItems={historyItems.slice(0, 10)} onReplayHistory={(id) => void handleReplayHistory(id)} /> - { - clearOpenFeedback(); - void handleOpenRecordingFiles(); - }} - onOpenDirectory={handleOpenDirectory} - onOpenTarPicker={() => document.getElementById('rosview-inline-tar')?.click()} - onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} - remoteSubmitLoading={remoteUrlBusy} - onSelectSample={handleSelectSample} - onRequestChangeRemoteUrl={openRemotePrompt} - historyItems={historyItems} - onReplayHistory={(id) => void handleReplayHistory(id)} - /> + {!lastLoadError && !manualOpenHint ? ( +
+
+ + +
+ +
+ ) : ( + { + clearOpenFeedback(); + void handleOpenRecordingFiles(); + }} + onOpenDirectory={handleOpenDirectory} + onOpenTarPicker={() => document.getElementById('rosview-inline-tar')?.click()} + onSubmitRemoteUrl={handleOpenRemoteRecordingUrl} + remoteSubmitLoading={remoteUrlBusy} + onSelectSample={handleSelectSample} + historyItems={historyItems} + onReplayHistory={(id) => void handleReplayHistory(id)} + /> + )} void; +} + +export const LoadingOverlay: React.FC = ({ sourceName, onCancel }) => { + const { formatMessage } = useIntl(); + + return ( +
+ + + + + {formatMessage({ id: 'welcome.loadingTitle' })} + + {sourceName ? ( +

+ {sourceName} +

+ ) : null} +

+ {formatMessage({ id: 'welcome.loadingPhase.preparing' })} +

+
+ {onCancel ? ( + + + + ) : null} +
+
+ ); +}; diff --git a/src/features/workspace/common/WelcomeScreen.tsx b/src/features/workspace/common/WelcomeScreen.tsx index 26c7e70..5b239a1 100644 --- a/src/features/workspace/common/WelcomeScreen.tsx +++ b/src/features/workspace/common/WelcomeScreen.tsx @@ -1,18 +1,13 @@ import React from 'react'; -import { Link2 } from 'lucide-react'; import { useIntl } from 'react-intl'; import type { SampleDataset } from '@/services/sampleDatasets'; import type { DatasetHistoryListItem } from '@/shared/utils/datasetHistory'; import { useSampleDatasets } from '@/hooks/useSampleDatasets'; import { DatasetSourceSelector } from '@/features/workspace/welcome/DatasetSourceSelector'; import { SampleDatasetList } from '@/features/workspace/welcome/SampleDatasetList'; -import { Button } from '@/shared/ui/button'; import { Separator } from '@/shared/ui/separator'; -import { Spinner } from '@/shared/ui/spinner'; interface WelcomeScreenProps { - isLoading?: boolean; - loadingSourceName?: string; manualOpenHint?: string | null; onOpenFile: () => void; onOpenDirectory: () => void; @@ -20,14 +15,11 @@ interface WelcomeScreenProps { onSubmitRemoteUrl: (url: string) => void | Promise; remoteSubmitLoading?: boolean; onSelectSample: (sample: SampleDataset) => void | Promise; - onRequestChangeRemoteUrl?: () => void; historyItems?: DatasetHistoryListItem[]; onReplayHistory?: (id: string) => void | Promise; } export const WelcomeScreen: React.FC = ({ - isLoading, - loadingSourceName, manualOpenHint, onOpenFile, onOpenDirectory, @@ -35,7 +27,6 @@ export const WelcomeScreen: React.FC = ({ onSubmitRemoteUrl, remoteSubmitLoading, onSelectSample, - onRequestChangeRemoteUrl, historyItems = [], onReplayHistory, }) => { @@ -45,31 +36,6 @@ export const WelcomeScreen: React.FC = ({ /** Show samples column while loading or when any samples exist; hide when none configured after load. */ const showSamplesSection = samplesLoading || hasSamples; - if (isLoading) { - return ( -
-
- -

- {formatMessage({ id: 'welcome.loadingTitle' })} -

- {loadingSourceName ? ( -

- {loadingSourceName} -

- ) : null} -

{formatMessage({ id: 'welcome.loadingHint' })}

-
- {onRequestChangeRemoteUrl ? ( - - ) : null} -
- ); - } - return (
diff --git a/src/features/workspace/sidebar/Sidebar.tsx b/src/features/workspace/sidebar/Sidebar.tsx index afb8ce5..59d7cbd 100644 --- a/src/features/workspace/sidebar/Sidebar.tsx +++ b/src/features/workspace/sidebar/Sidebar.tsx @@ -6,6 +6,7 @@ import type { MessagePipelineState } from '@/core/pipeline/store'; import type { Player } from '@/core/types/player'; import type { TopicInfo } from '@/core/types/ros'; import { ScrollArea } from '@/shared/ui/scroll-area'; +import { Skeleton } from '@/shared/ui/skeleton'; import { AlertTriangle, Database, Layers, Search, Settings2 } from 'lucide-react'; import { useSidebarStore } from '@/shared/hooks/useSidebarStore'; import { PanelSettingsTab } from './PanelSettingsTab'; @@ -22,6 +23,20 @@ import { cn } from '@/shared/lib/utils'; const SIDEBAR_TAB_TRIGGER_CLASS = 'flex flex-1 items-center justify-center gap-1 border-b-2 border-transparent px-2 py-2 text-xs font-medium transition-colors hover:bg-accent/70 data-[state=active]:border-primary data-[state=active]:bg-background data-[state=active]:text-primary'; +const TOPIC_LIST_SKELETON_ROWS = 8; + +function TopicRowSkeleton(): React.ReactElement { + return ( +
+
+ + +
+ +
+ ); +} + interface SidebarProps { player: Player; datasets: DatasetItem[]; @@ -50,6 +65,8 @@ export const Sidebar: React.FC = ({ }) => { const { formatMessage } = useIntl(); const topics = useMessagePipeline((state: MessagePipelineState) => state.sortedTopics); + const playerPresence = useMessagePipeline((state: MessagePipelineState) => state.playerState.presence); + const topicsLoading = playerPresence === 'preinit' || playerPresence === 'initializing'; const activeTab = useSidebarStore((s) => s.tab); const setActiveTab = useSidebarStore((s) => s.setTab); const initialSidebarTabAppliedRef = useRef(false); @@ -189,7 +206,11 @@ export const Sidebar: React.FC = ({
- {filteredTopics.length > 0 ? ( + {topicsLoading ? ( + Array.from({ length: TOPIC_LIST_SKELETON_ROWS }, (_, index) => ( + + )) + ) : filteredTopics.length > 0 ? ( filteredTopics.map((topic: TopicInfo) => ( ; + configureTransport: ReturnType; + getTransportDiagnostics: ReturnType; +}; + +let mockRemote: MockRemote; +let workerListeners: Map>; + +function createMockWorker(): Worker { + workerListeners = new Map(); + return { + addEventListener(type: string, listener: EventListener) { + let set = workerListeners.get(type); + if (!set) { + set = new Set(); + workerListeners.set(type, set); + } + set.add(listener); + }, + removeEventListener(type: string, listener: EventListener) { + workerListeners.get(type)?.delete(listener); + }, + terminate: vi.fn(), + } as unknown as Worker; +} + +function dispatchWorkerError(message: string): void { + const event = { message } as ErrorEvent; + for (const listener of workerListeners.get('error') ?? []) { + listener.call(globalThis, event); + } +} + +vi.mock('comlink', () => ({ + wrap: vi.fn(() => mockRemote), +})); + +vi.mock('./transports/createWorkerTransport', () => ({ + createWorkerTransport: vi.fn(() => ({ + configure: vi.fn(async () => undefined), + diagnostics: vi.fn(async () => ({ mode: 'comlink' as const })), + mode: () => 'comlink' as const, + fallbackReason: () => undefined, + })), +})); + +describe('WorkerSerializedSource.initialize', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockRemote = { + initialize: vi.fn(), + configureTransport: vi.fn(), + getTransportDiagnostics: vi.fn(), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('waits longer than the former 30s timeout without failing', async () => { + let resolveInitialize!: (value: Initialization) => void; + mockRemote.initialize.mockReturnValue( + new Promise((resolve) => { + resolveInitialize = resolve; + }), + ); + + const source = new WorkerSerializedSource(createMockWorker()); + const pending = source.initialize({ file: new Blob(['x']) }); + + await vi.advanceTimersByTimeAsync(60_000); + resolveInitialize(mockInitialization); + + await expect(pending).resolves.toEqual(mockInitialization); + }); + + it('propagates worker initialize rejection without a timed-out message', async () => { + mockRemote.initialize.mockRejectedValue(new Error('HTTP 404')); + + const source = new WorkerSerializedSource(createMockWorker()); + + await expect(source.initialize({ url: 'https://example.com/file.mcap' })).rejects.toThrow('HTTP 404'); + }); + + it('rejects with WorkerSourceCancelledError when terminate is called during initialize', async () => { + mockRemote.initialize.mockReturnValue(new Promise(() => undefined)); + + const worker = createMockWorker(); + const source = new WorkerSerializedSource(worker); + const pending = source.initialize({ file: new Blob(['x']) }); + + await Promise.resolve(); + source.terminate(); + + await expect(pending).rejects.toBeInstanceOf(WorkerSourceCancelledError); + expect(worker.terminate).toHaveBeenCalled(); + }); + + it('rejects when the worker emits an error event during initialize', async () => { + mockRemote.initialize.mockReturnValue(new Promise(() => undefined)); + + const source = new WorkerSerializedSource(createMockWorker()); + const pending = source.initialize({ file: new Blob(['x']) }); + + await Promise.resolve(); + dispatchWorkerError('Worker script failed'); + + await expect(pending).rejects.toThrow('Worker script failed'); + }); +}); diff --git a/src/infra/workers/WorkerSerializedSource.ts b/src/infra/workers/WorkerSerializedSource.ts index 7c7e53d..4d35b38 100644 --- a/src/infra/workers/WorkerSerializedSource.ts +++ b/src/infra/workers/WorkerSerializedSource.ts @@ -17,7 +17,16 @@ import { createWorkerTransport } from "./transports/createWorkerTransport"; import { SabTransport } from "./transports/SabTransport"; import type { WorkerTransport } from "./transports/BaseWorkerTransport"; -const INITIALIZE_TIMEOUT_MS = 30_000; +export class WorkerSourceCancelledError extends Error { + constructor() { + super("Worker initialize cancelled"); + this.name = "WorkerSourceCancelledError"; + } +} + +export function isWorkerSourceCancelledError(error: unknown): error is WorkerSourceCancelledError { + return error instanceof WorkerSourceCancelledError; +} type ResolveHighFrequencyLaneOptions = { preferSharedView?: boolean; @@ -39,6 +48,7 @@ export class WorkerSerializedSource { private _stalePayloadRefs = 0; private _ringReader?: SharedPayloadRing; private _workerFailure?: Error; + private _pendingInitializeReject?: (error: Error) => void; constructor(worker: Worker) { this._worker = worker; @@ -78,7 +88,7 @@ export class WorkerSerializedSource { this._transportConfigured = true; } const result = await this._raceWorkerFailure( - this._withTimeout(this._remote.initialize(sanitizedArgs), INITIALIZE_TIMEOUT_MS, "Worker initialize timed out"), + this._wrapAbortableInitialize(this._remote.initialize(sanitizedArgs)), ); console.log("WorkerSerializedSource: initialize result received"); return result; @@ -120,20 +130,32 @@ export class WorkerSerializedSource { }); } - private async _withTimeout(operation: Promise, timeoutMs: number, message: string): Promise { - let timeoutId: ReturnType | undefined; - try { - return await Promise.race([ - operation, - new Promise((_, reject) => { - timeoutId = globalThis.setTimeout(() => reject(new Error(message)), timeoutMs); - }), - ]); - } finally { - if (timeoutId !== undefined) { - globalThis.clearTimeout(timeoutId); - } - } + private _wrapAbortableInitialize(operation: Promise): Promise { + return new Promise((resolve, reject) => { + this._pendingInitializeReject = (error) => { + reject(error); + }; + operation.then( + (value) => { + this._clearPendingInitialize(); + resolve(value); + }, + (error: unknown) => { + this._clearPendingInitialize(); + reject(errorFromUnknown(error)); + }, + ); + }); + } + + private _clearPendingInitialize(): void { + this._pendingInitializeReject = undefined; + } + + private _abortPendingInitialize(error: Error): void { + const reject = this._pendingInitializeReject; + this._clearPendingInitialize(); + reject?.(error); } async getMessageCursor(args: MessageIteratorArgs): Promise> { @@ -242,7 +264,8 @@ export class WorkerSerializedSource { return this._fallbackReason; } - terminate() { + terminate(): void { + this._abortPendingInitialize(new WorkerSourceCancelledError()); this._worker.terminate(); } diff --git a/src/shared/intl/messages/en/welcome.json b/src/shared/intl/messages/en/welcome.json index bbf665b..13d8846 100644 --- a/src/shared/intl/messages/en/welcome.json +++ b/src/shared/intl/messages/en/welcome.json @@ -51,8 +51,8 @@ "welcome.fileTypes": "mcap · bag · db3 · hdf5 · bvh", "welcome.directoryHint": "All matching files in folder", "welcome.loadingTitle": "Opening…", - "welcome.loadingHint": "Parsing index — large or remote files may take a moment.", - "welcome.changeUrl": "Different URL", + "welcome.loadingPhase.preparing": "Preparing…", + "welcome.cancelLoading": "Cancel", "welcome.manualOpenFileHint": "Please manually open the {name} file.", "welcome.manualOpenFolderHint": "Please manually open the {name} folder." } diff --git a/src/shared/intl/messages/ja/welcome.json b/src/shared/intl/messages/ja/welcome.json index afdd1bb..55348f5 100644 --- a/src/shared/intl/messages/ja/welcome.json +++ b/src/shared/intl/messages/ja/welcome.json @@ -51,8 +51,8 @@ "welcome.fileTypes": "mcap · bag · db3 · hdf5 · bvh", "welcome.directoryHint": "フォルダ内の該当ファイルすべて", "welcome.loadingTitle": "開いています…", - "welcome.loadingHint": "索引を解析中です。大きなファイルは時間がかかることがあります。", - "welcome.changeUrl": "URL を変更", + "welcome.loadingPhase.preparing": "準備中…", + "welcome.cancelLoading": "キャンセル", "welcome.manualOpenFileHint": "{name} ファイルを手動で開いてください。", "welcome.manualOpenFolderHint": "{name} フォルダを手動で開いてください。" } diff --git a/src/shared/intl/messages/zh/welcome.json b/src/shared/intl/messages/zh/welcome.json index dba14b3..ba208f3 100644 --- a/src/shared/intl/messages/zh/welcome.json +++ b/src/shared/intl/messages/zh/welcome.json @@ -51,8 +51,8 @@ "welcome.fileTypes": "mcap · bag · db3 · hdf5 · bvh", "welcome.directoryHint": "导入文件夹内全部匹配文件", "welcome.loadingTitle": "正在打开…", - "welcome.loadingHint": "正在解析索引,大文件或远程链接可能稍慢。", - "welcome.changeUrl": "更换链接", + "welcome.loadingPhase.preparing": "准备中…", + "welcome.cancelLoading": "取消", "welcome.manualOpenFileHint": "请手动打开 {name} 文件。", "welcome.manualOpenFolderHint": "请手动打开 {name} 文件夹。" } diff --git a/src/shared/ui/skeleton.tsx b/src/shared/ui/skeleton.tsx new file mode 100644 index 0000000..0de8681 --- /dev/null +++ b/src/shared/ui/skeleton.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; + +import { cn } from '@/shared/lib/utils'; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export { Skeleton }; diff --git a/vite.config.ts b/vite.config.ts index cd55dbb..7bf8995 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -46,8 +46,24 @@ export default defineConfig({ }, }, optimizeDeps: { - // Only exclude direct WASM packages — Vite cannot pre-bundle their binary assets. - exclude: [ + // Playback workers are imported only after a dataset is opened. Pre-bundle their + // transitive parser/decompression deps up front so first-open in dev does not + // trigger Vite's "new dependencies optimized" reload and bounce back to home. + include: [ + '@foxglove/omgidl-parser', + '@foxglove/omgidl-serialization', + '@foxglove/ros2idl-parser', + '@foxglove/rosmsg', + '@foxglove/rosmsg-serialization', + '@foxglove/rosmsg2-serialization', + '@mcap/core', + 'eventemitter3', + 'flatbuffers/js/flexbuffers.js', + 'fzstd', + 'intervals-fn', + 'lz4js', + 'protobufjs', + 'protobufjs/ext/descriptor', '@ioai/hdf5', ], },