Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 17 additions & 5 deletions src/components/src/bottom-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright contributors to the kepler.gl project

import React, {forwardRef, useMemo, useCallback} from 'react';
import React, {forwardRef, memo, useMemo, useCallback} from 'react';
import styled, {withTheme, IStyledComponent} from 'styled-components';

import {FILTER_VIEW_TYPES, EXPORT_VIDEO_ID} from '@kepler.gl/constants';
Expand Down Expand Up @@ -232,10 +232,22 @@ export default function BottomWidgetFactory(
);
};

return withTheme(
forwardRef((props: BottomWidgetThemedProps, ref: React.ForwardedRef<HTMLDivElement>) => (
<BottomWidget {...props} rootRef={ref} />
))
// Wrap order matters for memo to be effective:
// 1. withTheme — injects `theme` prop from styled-components context
// 2. forwardRef — converts the React ref to the `rootRef` prop
// 3. memo — outermost guard; sees stable props after theme injection
//
// If memo wrapped a component that still had withTheme outside it, withTheme
// would produce a new props object on every render and bust the memo cache.
const ThemedBottomWidget = withTheme(BottomWidget);

const ForwardedBottomWidget = forwardRef(
(props: Omit<BottomWidgetThemedProps, 'rootRef' | 'theme'>, ref: React.ForwardedRef<HTMLDivElement>) => (
<ThemedBottomWidget {...(props as BottomWidgetThemedProps)} rootRef={ref} />
)
);
ForwardedBottomWidget.displayName = 'BottomWidget';

return memo(ForwardedBottomWidget) as unknown as React.FC<BottomWidgetThemedProps>;
}
/* eslint-enable complexity */
18 changes: 17 additions & 1 deletion src/components/src/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,23 @@ export const ERROR_MSG = {

const mapStateToProps = (state: any, props: ContainerProps) => ({state, ...props});
const dispatchToProps = (dispatch: Dispatch<any>) => ({dispatch});
const connector = connect(mapStateToProps, dispatchToProps);
const connector = connect(mapStateToProps, dispatchToProps, null, {
// Skip re-render when this kepler.gl instance slice is unchanged.
// The outer Container only uses `state` for the existence check (line below).
// All actual data subscriptions are handled by the inner KeplerGL via
// keplerGlConnect, which has its own independent Redux subscription.
areStatesEqual: (next: any, prev: any, nextOwnProps: ContainerProps) => {
const getState = nextOwnProps.getState || ((s: any) => s.keplerGl);
const id = nextOwnProps.id || 'map';
const nextInstance = getState(next)?.[id];
const prevInstance = getState(prev)?.[id];
// If neither instance exists yet, fall back to full equality check
if (!prevInstance && !nextInstance) {
return next === prev;
}
return nextInstance === prevInstance;
}
});

type ContainerProps = {
id: string;
Expand Down
44 changes: 35 additions & 9 deletions src/components/src/kepler-gl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,16 @@ export function getVisibleDatasets(datasets) {
return filterObjectByPredicate(datasets, key => key !== GEOCODER_DATASET_NAME);
}

export const sidePanelSelector = (props: KeplerGLProps, availableProviders, filteredDatasets) => ({
// sidePanelSelector is a plain function (not memoized at this level) because:
// 1. It receives `availableProviders` and `filteredDatasets` as extra args, which are
// already individually memoized at the instance level in KeplerGL.
// 2. A module-level createSelector with extra positional args only caches one result,
// so it would thrash on every render with multiple instances or changing extras.
export const sidePanelSelector = (
props: KeplerGLProps,
availableProviders,
filteredDatasets
) => ({
appName: props.appName ? props.appName : DEFAULT_KEPLER_GL_PROPS.appName,
version: props.version ? props.version : DEFAULT_KEPLER_GL_PROPS.version,
appWebsite: props.appWebsite,
Expand All @@ -219,7 +228,6 @@ export const sidePanelSelector = (props: KeplerGLProps, availableProviders, filt
visStateActions: props.visStateActions,
uiStateActions: props.uiStateActions,
mapStateActions: props.mapStateActions,

datasets: filteredDatasets,
filters: props.visState.filters,
layers: props.visState.layers,
Expand All @@ -229,7 +237,6 @@ export const sidePanelSelector = (props: KeplerGLProps, availableProviders, filt
mapInfo: props.visState.mapInfo,
layerBlending: props.visState.layerBlending,
overlayBlending: props.visState.overlayBlending,

width: props.sidePanelWidth ? props.sidePanelWidth : DEFAULT_KEPLER_GL_PROPS.width,
availableProviders,
mapSaved: props.providerState.mapSaved
Expand All @@ -256,6 +263,11 @@ export const plotContainerSelector = (props: KeplerGLProps) => ({
export const isSplitSelector = (props: KeplerGLProps) =>
props.visState.splitMaps && props.visState.splitMaps.length > 1;

// bottomWidgetSelector is a plain function (not memoized at this level) because:
// 1. It takes `theme` as a second positional argument.
// 2. A module-level createSelector with extra positional args only caches one result,
// so it would thrash on every render with multiple instances or changing themes.
// Individual fields (filters, layers, etc.) are already memoized by Redux selectors.
export const bottomWidgetSelector = (props: KeplerGLProps, theme) => ({
filters: props.visState.filters,
datasets: props.visState.datasets,
Expand All @@ -264,7 +276,9 @@ export const bottomWidgetSelector = (props: KeplerGLProps, theme) => ({
animationConfig: props.visState.animationConfig,
visStateActions: props.visStateActions,
toggleModal: props.uiStateActions.toggleModal,
sidePanelWidth: props.uiState.readOnly ? 0 : props.sidePanelWidth + theme.sidePanel.margin.left
sidePanelWidth: props.uiState.readOnly
? 0
: props.sidePanelWidth + theme.sidePanel.margin.left
});

export const modalContainerSelector = (props: KeplerGLProps, rootNode) => ({
Expand Down Expand Up @@ -414,10 +428,16 @@ export const attributionSelector = createSelector(
}
);

export const notificationPanelSelector = (props: KeplerGLProps) => ({
removeNotification: props.uiStateActions.removeNotification,
notifications: props.uiState.notifications
});
export const notificationPanelSelector = createSelector(
[
(props: KeplerGLProps) => props.uiStateActions.removeNotification,
(props: KeplerGLProps) => props.uiState.notifications
],
(removeNotification, notifications) => ({
removeNotification,
notifications
})
);

export const DEFAULT_KEPLER_GL_PROPS = {
mapStyles: [],
Expand Down Expand Up @@ -502,7 +522,13 @@ function KeplerGlFactory(
SidePanel: ReturnType<typeof SidePanelFactory>,
PlotContainer: ReturnType<typeof PlotContainerFactory>,
NotificationPanel: ReturnType<typeof NotificationPanelFactory>,
DndContext: ReturnType<typeof DndContextFactory>
DndContext: ReturnType<typeof DndContextFactory>,
// EffectManager is listed in deps so the injector graph includes it and
// consumers can override it via injectComponents([EffectManagerFactory, Custom]).
// KeplerGl itself does not render EffectManager — that is handled by
// MapControl in custom factory overrides (see examples/demo-app).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_EffectManager: ReturnType<typeof EffectManagerFactory>
): React.ComponentType<KeplerGLBasicProps & {selector: (...args: any[]) => KeplerGlState}> {
/** @typedef {import('./kepler-gl').UnconnectedKeplerGlProps} KeplerGlProps */
/** @augments React.Component<KeplerGlProps> */
Expand Down
239 changes: 239 additions & 0 deletions src/components/src/map/map-control.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: MIT
// Copyright contributors to the kepler.gl project

import {areMapControlPropsEqual, MapControlProps} from './map-control';

// ─── Helpers ───────────────────────────────────────────────────────────────

function makeLayer(overrides: Record<string, any> = {}): any {
return {
id: 'l1',
type: 'point',
config: {
isVisible: true,
label: 'Layer 1',
isConfigActive: false,
color: [255, 0, 0],
highlightColor: [0, 255, 0]
},
...overrides
};
}

function makeDataset(overrides: Record<string, any> = {}): any {
return {
id: 'ds1',
label: 'Dataset 1',
color: [0, 0, 255],
...overrides
};
}

// Stable shared objects — all tests that don't override a specific prop
// will share these references, so unrelated props always pass reference equality.
const STABLE = {
callbacks: {
onTogglePerspective: jest.fn(),
onToggleSplitMap: jest.fn() as any,
onToggleSplitMapViewport: jest.fn(),
onMapToggleLayer: jest.fn(),
onToggleMapControl: jest.fn(),
onSetEditorMode: jest.fn(),
onToggleEditorVisibility: jest.fn(),
onLayerVisConfigChange: jest.fn(),
onSetLocale: jest.fn() as any,
setMapControlSettings: jest.fn() as any
},
datasets: {} as any,
layers: [] as any[],
layerOrder: [] as any[],
layersToRender: {} as {[key: string]: boolean},
mapControls: {} as any,
editor: {} as any
};

function baseProps(): MapControlProps {
return {
...STABLE.callbacks,
datasets: STABLE.datasets,
dragRotate: false,
isSplit: false,
primary: true,
layers: STABLE.layers,
layerOrder: STABLE.layerOrder,
layersToRender: STABLE.layersToRender,
mapIndex: 0,
mapControls: STABLE.mapControls,
top: 0,
availableLocales: ['en'],
locale: 'en',
activeSidePanel: null,
editor: STABLE.editor
};
}

/**
* Create a prev/next pair where only the specified key differs.
* Both objects share the same base so all other prop references are identical.
*/
function pair(
key: keyof MapControlProps,
prevVal: any,
nextVal: any
): [MapControlProps, MapControlProps] {
const base = baseProps();
const prev = {...base, [key]: prevVal};
const next = {...base, [key]: nextVal};
return [prev, next];
}

const isEqual = areMapControlPropsEqual;

// ═══════════════════════════════════════════════════════════════════════════
// Baseline
// ═══════════════════════════════════════════════════════════════════════════

describe('areMapControlPropsEqual — baseline', () => {
test('returns true when props are identical references', () => {
const props = baseProps();
expect(isEqual(props, props)).toBe(true);
});

test('returns false when a simple scalar prop changes (e.g. locale)', () => {
const [prev, next] = pair('locale', 'en', 'fr');
expect(isEqual(prev, next)).toBe(false);
});

test('returns false when isSplit changes', () => {
const [prev, next] = pair('isSplit', false, true);
expect(isEqual(prev, next)).toBe(false);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// layers
// ═══════════════════════════════════════════════════════════════════════════

describe('areMapControlPropsEqual — layers', () => {
test('re-renders when a layer is added', () => {
const [prev, next] = pair('layers', [], [makeLayer()]);
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when layer.config.isVisible changes', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [
{...layer, config: {...layer.config, isVisible: false}}
]);
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when layer.config.label changes', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [
{...layer, config: {...layer.config, label: 'New'}}
]);
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when layer.config.color changes (legend color)', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [
{...layer, config: {...layer.config, color: [0, 255, 0]}}
]);
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when layer.config.highlightColor changes', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [
{...layer, config: {...layer.config, highlightColor: [255, 0, 255]}}
]);
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when layer.config.isConfigActive changes', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [
{...layer, config: {...layer.config, isConfigActive: true}}
]);
expect(isEqual(prev, next)).toBe(false);
});

test('does NOT re-render when layer object ref changes but all checked fields are the same', () => {
const layer = makeLayer();
const [prev, next] = pair('layers', [layer], [{...layer}]); // spread = new object, same values
expect(isEqual(prev, next)).toBe(true);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// datasets
// ═══════════════════════════════════════════════════════════════════════════

describe('areMapControlPropsEqual — datasets', () => {
test('re-renders when a dataset is added', () => {
const [prev, next] = pair('datasets', {}, {ds1: makeDataset()});
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when dataset label changes', () => {
const ds = makeDataset({label: 'old'});
const [prev, next] = pair('datasets', {ds1: ds}, {ds1: {...ds, label: 'new'}});
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when dataset color changes', () => {
const ds = makeDataset({color: [255, 0, 0]});
const [prev, next] = pair('datasets', {ds1: ds}, {ds1: {...ds, color: [0, 255, 0]}});
expect(isEqual(prev, next)).toBe(false);
});

test('does NOT re-render when dataset object is recreated with same values', () => {
const ds = makeDataset();
const [prev, next] = pair('datasets', {ds1: ds}, {ds1: {...ds}});
expect(isEqual(prev, next)).toBe(true);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// layersToRender
// ═══════════════════════════════════════════════════════════════════════════

describe('areMapControlPropsEqual — layersToRender', () => {
test('re-renders when a layer visibility entry changes', () => {
const [prev, next] = pair('layersToRender', {l1: true}, {l1: false});
expect(isEqual(prev, next)).toBe(false);
});

test('re-renders when a new layer visibility entry is added', () => {
const [prev, next] = pair('layersToRender', {l1: true}, {l1: true, l2: true});
expect(isEqual(prev, next)).toBe(false);
});

test('does NOT re-render when layersToRender object is recreated with same values', () => {
const [prev, next] = pair('layersToRender', {l1: true, l2: false}, {l1: true, l2: false});
expect(isEqual(prev, next)).toBe(true);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// mapControls (unhandled prop — reference equality)
// ═══════════════════════════════════════════════════════════════════════════

describe('areMapControlPropsEqual — mapControls (reference equality)', () => {
test('re-renders when mapControls reference changes', () => {
const [prev, next] = pair(
'mapControls',
{mapLegend: {show: true}} as any,
{mapLegend: {show: true}} as any // new object
);
expect(isEqual(prev, next)).toBe(false);
});

test('does NOT re-render when mapControls reference is the same', () => {
const mapControls = {mapLegend: {show: true}} as any;
const [prev, next] = pair('mapControls', mapControls, mapControls);
expect(isEqual(prev, next)).toBe(true);
});
});
Loading
Loading