Skip to content

Commit

Permalink
feat(react) Support for JSX Widgets in React (#9278)
Browse files Browse the repository at this point in the history
* Add DeckGLContext

  * chore(react) all children are provided a DeckContext by default

* Add useWidget hook

* chore(main) export react components for each widget

* React widgets should warn when pure-js widgets are used

* Widgets should be removed when JSX unmounts

* chore(react) widgets should be reset when omitted in react

* chore(react) widget prop warning shouldn't warn for empty array

* chore(react) use consistent naming between react and purejs widgets

* Should use @deck.gl/react for all widget imports

---------

Signed-off-by: Chris Gervang <chris@gervang.com>
  • Loading branch information
chrisgervang authored Jan 16, 2025
1 parent 6625885 commit 7d046e6
Show file tree
Hide file tree
Showing 16 changed files with 284 additions and 40 deletions.
3 changes: 3 additions & 0 deletions examples/get-started/react/basic/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import DeckGL, {GeoJsonLayer, ArcLayer} from 'deck.gl';
import {CompassWidget} from '@deck.gl/react';
import '@deck.gl/widgets/stylesheet.css';

// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
const COUNTRIES =
Expand Down Expand Up @@ -62,6 +64,7 @@ function Root() {
getTargetColor={[200, 0, 80]}
getWidth={1}
/>
<CompassWidget />
</DeckGL>
);
}
Expand Down
1 change: 1 addition & 0 deletions modules/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"scripts": {},
"peerDependencies": {
"@deck.gl/core": "^9.1.0-beta",
"@deck.gl/widgets": "^9.1.0-beta",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
Expand Down
3 changes: 2 additions & 1 deletion modules/react/src/deckgl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import extractJSXLayers, {DeckGLRenderCallback} from './utils/extract-jsx-layers
import positionChildrenUnderViews from './utils/position-children-under-views';
import extractStyles from './utils/extract-styles';

import type {DeckGLContextValue} from './utils/position-children-under-views';
import type {DeckGLContextValue} from './utils/deckgl-context';
import type {DeckProps, View, Viewport} from '@deck.gl/core';

export type ViewOrViews = View | View[] | null;
Expand Down Expand Up @@ -157,6 +157,7 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
// Needs to be called both from initial mount, and when new props are received
const deckProps = useMemo(() => {
const forwardProps: DeckProps<ViewsT> = {
widgets: [],
...props,
// Override user styling props. We will set the canvas style in render()
style: null,
Expand Down
8 changes: 7 additions & 1 deletion modules/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
export {default as DeckGL} from './deckgl';
export {default} from './deckgl';

// Widgets
export {CompassWidget} from './widgets/compass-widget';
export {FullscreenWidget} from './widgets/fullscreen-widget';
export {ZoomWidget} from './widgets/zoom-widget';
export {useWidget} from './utils/use-widget';

// Types
export type {DeckGLContextValue} from './utils/position-children-under-views';
export type {DeckGLContextValue} from './utils/deckgl-context';
export type {DeckGLRef, DeckGLProps} from './deckgl';
15 changes: 15 additions & 0 deletions modules/react/src/utils/deckgl-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {createContext} from 'react';
import type {EventManager} from 'mjolnir.js';
import type {Deck, DeckProps, Viewport, Widget} from '@deck.gl/core';

export type DeckGLContextValue = {
viewport: Viewport;
container: HTMLElement;
eventManager: EventManager;
onViewStateChange: DeckProps['onViewStateChange'];
deck?: Deck<any>;
widgets?: Widget[];
};

// @ts-ignore
export const DeckGlContext = createContext<DeckGLContextValue>();
46 changes: 19 additions & 27 deletions modules/react/src/utils/position-children-under-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,15 @@ import {inheritsFrom} from './inherits-from';
import evaluateChildren, {isComponent} from './evaluate-children';

import type {ViewOrViews} from '../deckgl';
import type {Deck, DeckProps, Viewport} from '@deck.gl/core';
import type {EventManager} from 'mjolnir.js';

export type DeckGLContextValue = {
viewport: Viewport;
container: HTMLElement;
eventManager: EventManager;
onViewStateChange: DeckProps['onViewStateChange'];
};
import type {Deck, Viewport} from '@deck.gl/core';
import {DeckGlContext, type DeckGLContextValue} from './deckgl-context';

// Iterate over views and reposition children associated with views
// TODO - Can we supply a similar function for the non-React case?
export default function positionChildrenUnderViews<ViewsT extends ViewOrViews>({
children,
deck,
ContextProvider
ContextProvider = DeckGlContext.Provider
}: {
children: React.ReactNode[];
deck?: Deck<ViewsT>;
Expand Down Expand Up @@ -101,22 +94,21 @@ export default function positionChildrenUnderViews<ViewsT extends ViewOrViews>({
// a key" warning. Sending each child as separate arguments removes this requirement.
const viewElement = createElement('div', {key, id: key, style}, ...viewChildren);

if (ContextProvider) {
const contextValue: DeckGLContextValue = {
viewport,
// @ts-expect-error accessing protected property
container: deck.canvas.offsetParent,
// @ts-expect-error accessing protected property
eventManager: deck.eventManager,
onViewStateChange: params => {
params.viewId = viewId;
// @ts-expect-error accessing protected method
deck._onViewStateChange(params);
}
};
return createElement(ContextProvider, {key, value: contextValue}, viewElement);
}

return viewElement;
const contextValue: DeckGLContextValue = {
deck,
viewport,
// @ts-expect-error accessing protected property
container: deck.canvas.offsetParent,
// @ts-expect-error accessing protected property
eventManager: deck.eventManager,
onViewStateChange: params => {
params.viewId = viewId;
// @ts-expect-error accessing protected method
deck._onViewStateChange(params);
},
widgets: []
};
const providerKey = `view-${viewId}-context`;
return createElement(ContextProvider, {key: providerKey, value: contextValue}, viewElement);
});
}
39 changes: 39 additions & 0 deletions modules/react/src/utils/use-widget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {useContext, useMemo, useEffect} from 'react';
import {DeckGlContext} from './deckgl-context';
import {log, type Widget, _deepEqual as deepEqual} from '@deck.gl/core';

export function useWidget<T extends Widget, PropsT extends {}>(
WidgetClass: {new (props: PropsT): T},
props: PropsT
): T {
const context = useContext(DeckGlContext);
const {widgets, deck} = context;
useEffect(() => {
// warn if the user supplied a pure-js widget, since it will be ignored
// NOTE: This effect runs once per widget. Context widgets and deck widget props are synced after first effect runs.
const internalWidgets = deck?.props.widgets;
if (widgets?.length && internalWidgets?.length && !deepEqual(internalWidgets, widgets, 1)) {
log.warn('"widgets" prop will be ignored because React widgets are in use.')();
}

return () => {
// Remove widget from context when it is unmounted
const index = widgets?.indexOf(widget);
if (index && index !== -1) {
widgets?.splice(index, 1);
deck?.setProps({widgets});
}
};
}, []);
const widget = useMemo(() => new WidgetClass(props), [WidgetClass]);

// Hook rebuilds widgets on every render: [] then [FirstWidget] then [FirstWidget, SecondWidget]
widgets?.push(widget);
widget.setProps(props);

useEffect(() => {
deck?.setProps({widgets});
}, [widgets]);

return widget;
}
8 changes: 8 additions & 0 deletions modules/react/src/widgets/compass-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {CompassWidget as _CompassWidget} from '@deck.gl/widgets';
import type {CompassWidgetProps} from '@deck.gl/widgets';
import {useWidget} from '../utils/use-widget';

export const CompassWidget = (props: CompassWidgetProps = {}) => {
const widget = useWidget(_CompassWidget, props);
return null;
};
8 changes: 8 additions & 0 deletions modules/react/src/widgets/fullscreen-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {FullscreenWidget as _FullscreenWidget} from '@deck.gl/widgets';
import type {FullscreenWidgetProps} from '@deck.gl/widgets';
import {useWidget} from '../utils/use-widget';

export const FullscreenWidget = (props: FullscreenWidgetProps = {}) => {
const widget = useWidget(_FullscreenWidget, props);
return null;
};
8 changes: 8 additions & 0 deletions modules/react/src/widgets/zoom-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {ZoomWidget as _ZoomWidget} from '@deck.gl/widgets';
import type {ZoomWidgetProps} from '@deck.gl/widgets';
import {useWidget} from '../utils/use-widget';

export const ZoomWidget = (props: ZoomWidgetProps = {}) => {
const widget = useWidget(_ZoomWidget, props);
return null;
};
3 changes: 2 additions & 1 deletion modules/react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"outDir": "dist"
},
"references": [
{"path": "../core"}
{"path": "../core"},
{"path": "../widgets"}
]
}
45 changes: 45 additions & 0 deletions test/modules/core/lib/deck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import test from 'tape-promise/tape';
import {Deck, log, MapView} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';
import {FullscreenWidget} from '@deck.gl/widgets';
import {device} from '@deck.gl/test-utils';
import {sleep} from './async-iterator-test-utils';

Expand Down Expand Up @@ -280,3 +281,47 @@ test('Deck#resourceManager', async t => {
deck.finalize();
t.end();
});

test('Deck#props omitted are unchanged', async t => {
const layer = new ScatterplotLayer({
id: 'scatterplot-global-data',
data: 'deck://pins',
getPosition: d => d.position
});

const widget = new FullscreenWidget({});

// Initialize with widgets and layers.
const deck = new Deck({
device,
width: 1,
height: 1,

viewState: {
longitude: 0,
latitude: 0,
zoom: 0
},

layers: [layer],
widgets: [widget],

onLoad: () => {
const {widgets, layers} = deck.props;
t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets is set');
t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers is set');

// Render deck a second time without changing widget or layer props.
deck.setProps({
onAfterRender: () => {
const {widgets, layers} = deck.props;
t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets remain set');
t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers remain set');

deck.finalize();
t.end();
}
});
}
});
});
16 changes: 15 additions & 1 deletion test/modules/core/lib/widget-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,24 @@ test('WidgetManager#setProps', t => {
t.notOk(widgetB.isVisible, 'widget.onRemove is called');
t.ok(widgetB2.isVisible, 'widget.onAdd is called');

widgetManager.setProps({widgets: []});
t.is(widgetManager.getWidgets().length, 0, 'all widgets are removed');
t.notOk(widgetB2.isVisible, 'widget.onRemove is called');

t.end();
});

test('WidgetManager#finalize', t => {
const container = document.createElement('div');
const widgetManager = new WidgetManager({deck: mockDeckInstance, parentElement: container});

const widgetA = new TestWidget({id: 'A'});
widgetManager.setProps({widgets: [widgetA]});

widgetManager.finalize();
t.is(widgetManager.getWidgets().length, 0, 'all widgets are removed');
t.is(container.childElementCount, 0, 'all widget containers are removed');
t.notOk(widgetB2.isVisible, 'widget.onRemove is called');
t.notOk(widgetA.isVisible, 'widget.onRemove is called');

t.end();
});
Expand Down
76 changes: 75 additions & 1 deletion test/modules/react/deckgl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {createElement, createRef} from 'react';
import {createRoot} from 'react-dom/client';
import {act} from 'react-dom/test-utils';

import DeckGL from 'deck.gl';
import DeckGL, {Layer} from 'deck.gl';

import {gl} from '@deck.gl/test-utils';

Expand Down Expand Up @@ -84,3 +84,77 @@ test('DeckGL#render', t => {
)
);
});

class TestLayer extends Layer {
initializeState() {}
}

TestLayer.layerName = 'TestLayer';

const LAYERS = [new TestLayer({id: 'primitive'})];

class TestWidget {
constructor(props) {
this.id = props.id;
}
onAdd() {}
}

const WIDGETS = [new TestWidget({id: 'A'})];

test('DeckGL#props omitted are reset', t => {
const ref = createRef();
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);

// Initialize widgets and layers on first render.
act(() => {
root.render(
createElement(DeckGL, {
initialViewState: TEST_VIEW_STATE,
ref,
width: 100,
height: 100,
gl: getMockContext(),
layers: LAYERS,
widgets: WIDGETS,
onLoad: () => {
const {deck} = ref.current;
t.ok(deck, 'DeckGL is initialized');
const {widgets, layers} = deck.props;
t.is(widgets && Array.isArray(widgets) && widgets.length, 1, 'Widgets is set');
t.is(layers && Array.isArray(layers) && layers.length, 1, 'Layers is set');

act(() => {
// Render deck a second time without setting widget or layer props.
root.render(
createElement(DeckGL, {
ref,
onAfterRender: () => {
const {deck} = ref.current;
const {widgets, layers} = deck.props;
t.is(
widgets && Array.isArray(widgets) && widgets.length,
0,
'Widgets is reset to an empty array'
);
t.is(
layers && Array.isArray(layers) && layers.length,
0,
'Layers is reset to an empty array'
);

root.render(null);
container.remove();
t.end();
}
})
);
});
}
})
);
});
t.ok(ref.current, 'DeckGL overlay is rendered.');
});
Loading

0 comments on commit 7d046e6

Please sign in to comment.