Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6892c53
POC: PlotTwo using subscribeMessageRange
rodrigo-rodrigues-ctw Mar 20, 2026
5d2961f
rework Plot Two
rodrigo-rodrigues-ctw Mar 24, 2026
cd34b93
refactor CurrentCustomDatasetsBuilder and IndexDatasetsBuilder
rodrigo-rodrigues-ctw Mar 24, 2026
02b3f2f
remove rename config action
rodrigo-rodrigues-ctw Mar 24, 2026
679a654
use subscribeMessageRange in CustomDatasetsBuilderTwo
rodrigo-rodrigues-ctw Mar 24, 2026
87d8bd7
rename keyUpHandlers
rodrigo-rodrigues-ctw Mar 24, 2026
00f5aa9
deduplicate types from utils
rodrigo-rodrigues-ctw Mar 24, 2026
c5fcd44
lint
rodrigo-rodrigues-ctw Mar 24, 2026
cdf4890
fix lint/tests
rodrigo-rodrigues-ctw Mar 25, 2026
a45fe12
refactor into helpers to reduce complexity (sonar)
rodrigo-rodrigues-ctw Mar 26, 2026
ddbe52c
add unit tests to useSubscribeMessageRange
rodrigo-rodrigues-ctw Mar 26, 2026
ca561e0
add logger test to useSubscriberMessageRange
rodrigo-rodrigues-ctw Mar 26, 2026
8f12a77
apply plot two changes to plot
rodrigo-rodrigues-ctw Mar 26, 2026
9009460
remove logger test in useSubscriberMessageRange
rodrigo-rodrigues-ctw Mar 26, 2026
6890126
fix tests
rodrigo-rodrigues-ctw Mar 26, 2026
4b69342
remove plot two
rodrigo-rodrigues-ctw Mar 26, 2026
714bc86
resolve sonar issues
rodrigo-rodrigues-ctw Mar 26, 2026
70d86e5
fix tests
rodrigo-rodrigues-ctw Mar 26, 2026
0c39109
remove blockTopicCursor
rodrigo-rodrigues-ctw Mar 26, 2026
0bddfc7
remove block mentions in indexDatasetsBuilder
rodrigo-rodrigues-ctw Mar 26, 2026
47291a5
improve coverage
rodrigo-rodrigues-ctw Mar 26, 2026
ca327e4
lint
rodrigo-rodrigues-ctw Mar 26, 2026
25559e1
address pr review comments
rodrigo-rodrigues-ctw Mar 27, 2026
af47405
remove unnecessary import
rodrigo-rodrigues-ctw Mar 27, 2026
b190c21
remove commited file by accident
rodrigo-rodrigues-ctw Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ import { assertNever } from "@lichtblick/suite-base/util/assertNever";
import { maybeCast } from "@lichtblick/suite-base/util/maybeCast";

import { PanelConfigVersionError } from "./PanelConfigVersionError";
import { createMessageRangeIterator } from "./messageRangeIterator";
import { RenderStateConfig, initRenderStateBuilder } from "./renderState";
import { BuiltinPanelExtensionContext, MessageConverterAlertHandler } from "./types";
import { useSharedPanelState } from "./useSharedPanelState";
import { useSubscribeMessageRange } from "./useSubscribeMessageRange";

const log = Logger.getLogger(__filename);

Expand Down Expand Up @@ -134,7 +134,6 @@ function PanelExtensionAdapter(
getMetadata,
sortedTopics,
sortedServices,
getBatchIterator,
} = messagePipelineContext;

const { capabilities, profile: dataSourceProfile, presence: playerPresence } = playerState;
Expand Down Expand Up @@ -202,6 +201,8 @@ function PanelExtensionAdapter(
[setAlert],
);

const subscribeMessageRange = useSubscribeMessageRange(emitMessageConverterAlert);

// Register handlers to update the app settings we subscribe to
useEffect(() => {
const handlers = new Map<string, (newValue: AppSettingValue) => void>();
Expand Down Expand Up @@ -606,32 +607,11 @@ function PanelExtensionAdapter(
* - Error handling is still being refined
* - API surface may change based on testing feedback
*/
unstable_subscribeMessageRange({ topic, convertTo, onNewRangeIterator }) {
unstable_subscribeMessageRange(args) {
if (!isMounted()) {
return () => {};
}

const rawBatchIterator = getBatchIterator(topic);
if (!rawBatchIterator) {
// If no batch iterator is available, just return an empty cleanup function
return () => {};
}

const { iterable: messageEventIterable, cancel } = createMessageRangeIterator({
topic,
convertTo,
rawBatchIterator,
sortedTopics,
messageConverters: messageConverters ?? [],
emitAlert: emitMessageConverterAlert,
});

// Call the callback with the processed iterable
onNewRangeIterator(messageEventIterable).catch((err: unknown) => {
log.error("Error in onNewRangeIterator callback:", err);
});

return cancel;
return subscribeMessageRange(args);
},

unstable_setMessagePathDropConfig(dropConfig) {
Expand Down Expand Up @@ -660,7 +640,7 @@ function PanelExtensionAdapter(
updatePanelSettingsTree,
setDefaultPanelTitle,
setMessagePathDropConfig,
emitMessageConverterAlert,
subscribeMessageRange,
]);

const panelContainerRef = useRef<HTMLDivElement>(ReactNull);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/

export { default as PanelExtensionAdapter } from "./PanelExtensionAdapter";
export { useSubscribeMessageRange } from "./useSubscribeMessageRange";
export type {
Asset,
BuiltinPanelExtensionContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

import { useCallback, useRef } from "react";

import Logger from "@lichtblick/log";
import { SubscribeMessageRangeArgs } from "@lichtblick/suite";
import { useMessagePipelineGetter } from "@lichtblick/suite-base/components/MessagePipeline";
import {
ExtensionCatalog,
useExtensionCatalog,
} from "@lichtblick/suite-base/context/ExtensionCatalogContext";

import { createMessageRangeIterator } from "./messageRangeIterator";
import { MessageConverterAlertHandler } from "./types";

const log = Logger.getLogger(__filename);

const selectInstalledMessageConverters = (state: ExtensionCatalog) =>
state.installedMessageConverters;

export type UseSubscribeMessageRange = (args: SubscribeMessageRangeArgs) => () => void;

/**
* Returns a stable callback that can be used to subscribe to a message range for a topic.
* This centralizes the logic of `unstable_subscribeMessageRange` so it can be used both by
* PanelExtensionAdapter and directly by built-in panels (e.g. Plot) without requiring migration
* to the PanelExtensionContext API.
*/
export function useSubscribeMessageRange(
emitAlert?: MessageConverterAlertHandler,
): UseSubscribeMessageRange {
// useMessagePipelineGetter returns a stable getter, so it's safe to call it inside the callback without adding it to dependencies.
const getMessagePipelineContext = useMessagePipelineGetter();

// Keep messageConverters in a ref so changing converters don't invalidate the callback.
const messageConverters = useExtensionCatalog(selectInstalledMessageConverters);
const messageConvertersRef = useRef(messageConverters);
messageConvertersRef.current = messageConverters;

// Similarly keep emitAlert in a ref so the caller can update it without breaking stability.
const emitAlertRef = useRef(emitAlert);
emitAlertRef.current = emitAlert;

return useCallback(
({ topic, convertTo, onNewRangeIterator }: SubscribeMessageRangeArgs) => {
const { sortedTopics, getBatchIterator } = getMessagePipelineContext();

const rawBatchIterator = getBatchIterator(topic);
if (!rawBatchIterator) {
return () => {};
}

const { iterable: messageEventIterable, cancel } = createMessageRangeIterator({
topic,
convertTo,
rawBatchIterator,
sortedTopics,
messageConverters: messageConvertersRef.current ?? [],
emitAlert: emitAlertRef.current,
});

onNewRangeIterator(messageEventIterable).catch((err: unknown) => {
log.error("Error in useSubscribeMessageRange onNewRangeIterator:", err);
});

return cancel;
},
[getMessagePipelineContext], // getMessagePipelineContext is already stable, but listed for clarity
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/** @jest-environment jsdom */

// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
// SPDX-License-Identifier: MPL-2.0

import { renderHook, act } from "@testing-library/react";

import { MessageEvent } from "@lichtblick/suite";
import { useMessagePipelineGetter } from "@lichtblick/suite-base/components/MessagePipeline";
import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
import { BasicBuilder } from "@lichtblick/test-builders";

import { createMessageRangeIterator } from "./messageRangeIterator";
import { useSubscribeMessageRange } from "./useSubscribeMessageRange";

jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({
useMessagePipelineGetter: jest.fn(),
}));

jest.mock("@lichtblick/suite-base/context/ExtensionCatalogContext", () => ({
useExtensionCatalog: jest.fn(),
}));

jest.mock("./messageRangeIterator", () => ({
createMessageRangeIterator: jest.fn(),
}));

const mockUseMessagePipelineGetter = useMessagePipelineGetter as jest.Mock;
const mockUseExtensionCatalog = useExtensionCatalog as jest.Mock;
const mockCreateMessageRangeIterator = createMessageRangeIterator as jest.Mock;

describe("useSubscribeMessageRange", () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseExtensionCatalog.mockReturnValue([]);
});

it("does not call onNewRangeIterator when batch iterator is unavailable", () => {
// Given
mockUseMessagePipelineGetter.mockReturnValue(
jest.fn().mockReturnValue({
sortedTopics: [],
getBatchIterator: jest.fn().mockReturnValue(undefined),
}),
);
const onNewRangeIterator = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useSubscribeMessageRange());

// When
act(() => {
result.current({ topic: BasicBuilder.string(), onNewRangeIterator });
});

// Then
expect(onNewRangeIterator).not.toHaveBeenCalled();
});

it("returns a callable cancel function when batch iterator is unavailable", () => {
// Given
mockUseMessagePipelineGetter.mockReturnValue(
jest.fn().mockReturnValue({
sortedTopics: [],
getBatchIterator: jest.fn().mockReturnValue(undefined),
}),
);
const onNewRangeIterator = jest.fn().mockResolvedValue(async () => {});
const { result } = renderHook(() => useSubscribeMessageRange());
let cancel!: () => void;

// When
act(() => {
cancel = result.current({ topic: BasicBuilder.string(), onNewRangeIterator });
});

// Then
expect(() => {
cancel();
}).not.toThrow();
});

it("calls onNewRangeIterator with the iterable when batch iterator is available", () => {
// Given
const topic = BasicBuilder.string();
const mockIterable: AsyncIterable<MessageEvent[]> = { [Symbol.asyncIterator]: jest.fn() };
const mockCancel = jest.fn();
let cancel!: () => void;
const mockBatchIterator = { [Symbol.asyncIterator]: jest.fn() };
const onNewRangeIterator = jest.fn().mockResolvedValue(async () => {});
mockCreateMessageRangeIterator.mockReturnValue({ iterable: mockIterable, cancel: mockCancel });
const mockGetBatchIterator = jest.fn().mockReturnValue(mockBatchIterator);
mockUseMessagePipelineGetter.mockReturnValue(
jest.fn().mockReturnValue({ sortedTopics: [], getBatchIterator: mockGetBatchIterator }),
);

const { result } = renderHook(() => useSubscribeMessageRange());

// When
act(() => {
cancel = result.current({ topic, onNewRangeIterator });
});

// Then
expect(mockGetBatchIterator).toHaveBeenCalledWith(topic);
expect(onNewRangeIterator).toHaveBeenCalledWith(mockIterable);
expect(cancel).toBe(mockCancel);
expect(mockCreateMessageRangeIterator).toHaveBeenCalledWith(
expect.objectContaining({
topic,
rawBatchIterator: mockBatchIterator,
sortedTopics: [],
}),
);
});
});
15 changes: 13 additions & 2 deletions packages/suite-base/src/panels/Plot/Plot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jest.mock("@lichtblick/suite-base/components/PanelContext", () => ({

const mockGetMessagePipelineState = jest.fn();
const mockSubscribeMessagePipeline = jest.fn();
const mockSubscribeMessageRange = jest.fn();
jest.mock("@lichtblick/suite-base/components/PanelExtensionAdapter", () => ({
useSubscribeMessageRange: () => mockSubscribeMessageRange,
}));

jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({
useMessagePipelineGetter: () => mockGetMessagePipelineState,
useMessagePipelineSubscribe: () => mockSubscribeMessagePipeline,
Expand Down Expand Up @@ -88,7 +93,9 @@ jest.mock("./hooks/usePlotInteractionHandlers", () => ({
let mockCoordinatorInstance: any;
const mockPlotCoordinatorCtor = jest.fn();
jest.mock("./PlotCoordinator", () => ({
PlotCoordinator: jest.fn((renderer, builder) => mockPlotCoordinatorCtor(renderer, builder)),
PlotCoordinator: jest.fn((renderer, builder, subscribeMessageRange) =>
mockPlotCoordinatorCtor(renderer, builder, subscribeMessageRange),
),
}));

const rendererStub = { id: "renderer" } as any;
Expand Down Expand Up @@ -312,7 +319,11 @@ describe("Plot Component", () => {
unmount();

// Then
expect(mockPlotCoordinatorCtor).toHaveBeenCalledWith(rendererStub, datasetsBuilderStub);
expect(mockPlotCoordinatorCtor).toHaveBeenCalledWith(
rendererStub,
datasetsBuilderStub,
mockSubscribeMessageRange,
);
expect(mockCoordinatorInstance.setSize).toHaveBeenCalledWith({
width: expect.any(Number),
height: expect.any(Number),
Expand Down
10 changes: 6 additions & 4 deletions packages/suite-base/src/panels/Plot/Plot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "@lichtblick/suite-base/components/MessagePipeline";
import { usePanelContext } from "@lichtblick/suite-base/components/PanelContext";
import { PanelContextMenu } from "@lichtblick/suite-base/components/PanelContextMenu";
import { useSubscribeMessageRange } from "@lichtblick/suite-base/components/PanelExtensionAdapter";
import PanelToolbar from "@lichtblick/suite-base/components/PanelToolbar";
import { PANEL_TOOLBAR_MIN_HEIGHT } from "@lichtblick/suite-base/components/PanelToolbar/constants";
import Stack from "@lichtblick/suite-base/components/Stack";
Expand Down Expand Up @@ -69,6 +70,7 @@ const Plot = (props: PlotProps): React.JSX.Element => {
const { globalVariables } = useGlobalVariables();
const getMessagePipelineState = useMessagePipelineGetter();
const subscribeMessagePipeline = useMessagePipelineSubscribe();
const subscribeMessageRange = useSubscribeMessageRange();

const {
onMouseMove,
Expand All @@ -79,7 +81,7 @@ const Plot = (props: PlotProps): React.JSX.Element => {
onClickPath,
focusedPath,
keyDownHandlers,
keyUphandlers,
keyUpHandlers,
getPanelContextMenuItems,
} = usePlotInteractionHandlers({
config,
Expand Down Expand Up @@ -164,7 +166,7 @@ const Plot = (props: PlotProps): React.JSX.Element => {

const contentRect = canvasDiv.getBoundingClientRect();

const plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder);
const plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder, subscribeMessageRange);
setCoordinator(plotCoordinator);

plotCoordinator.setSize({
Expand All @@ -188,7 +190,7 @@ const Plot = (props: PlotProps): React.JSX.Element => {
resizeObserver.disconnect();
plotCoordinator.destroy();
};
}, [canvasDiv, datasetsBuilder, renderer]);
}, [canvasDiv, datasetsBuilder, renderer, subscribeMessageRange]);

const numSeries = config.paths.length;
const tooltipContent = useMemo(() => {
Expand Down Expand Up @@ -287,7 +289,7 @@ const Plot = (props: PlotProps): React.JSX.Element => {
)}
<PanelContextMenu getItems={getPanelContextMenuItems} />
</Stack>
<KeyListener global keyDownHandlers={keyDownHandlers} keyUpHandlers={keyUphandlers} />
<KeyListener global keyDownHandlers={keyDownHandlers} keyUpHandlers={keyUpHandlers} />
</Stack>
);
};
Expand Down
Loading
Loading