From 412424cc92d7d55b18cfdd241066286958e83c31 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 18:10:57 +0800 Subject: [PATCH 1/3] feat: improve loading UX with in-player overlay and cancellable worker init Show the player shell with a loading overlay and skeleton placeholders instead of the welcome screen during open, add sidebar topic skeletons, and remove the fixed worker initialize timeout so large remote files can load until the user cancels. --- src/app/AppShell.tsx | 4 +- src/features/viewer/RosViewContent.tsx | 56 +++++--- src/features/viewer/RosViewerImpl.tsx | 54 +++++--- .../workspace/common/LoadingOverlay.tsx | 47 +++++++ .../workspace/common/WelcomeScreen.tsx | 34 ----- src/features/workspace/sidebar/Sidebar.tsx | 23 ++- .../workers/WorkerSerializedSource.test.ts | 131 ++++++++++++++++++ src/infra/workers/WorkerSerializedSource.ts | 57 +++++--- src/shared/intl/messages/en/welcome.json | 4 +- src/shared/intl/messages/ja/welcome.json | 4 +- src/shared/intl/messages/zh/welcome.json | 4 +- src/shared/ui/skeleton.tsx | 9 ++ 12 files changed, 328 insertions(+), 99 deletions(-) create mode 100644 src/features/workspace/common/LoadingOverlay.tsx create mode 100644 src/infra/workers/WorkerSerializedSource.test.ts create mode 100644 src/shared/ui/skeleton.tsx 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 }; From 39ba09e658a248f1bdbaa5f6847f350aae38f3e1 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 18:16:05 +0800 Subject: [PATCH 2/3] feat: pre-bundle transitive dependencies for playback workers Include necessary parser and decompression dependencies in the Vite configuration to enhance the loading experience by preventing unnecessary reloads when opening datasets in development. --- vite.config.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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', ], }, From 7b79fbbddab5c4f2f7e1831d7bca7aa85018a2d0 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 18:17:16 +0800 Subject: [PATCH 3/3] chore: bump version to 1.3.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",