Skip to content
Draft
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
99 changes: 96 additions & 3 deletions modules/core/src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,15 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
};
private _metricsCounter: number = 0;

/**
* Tracks which props were explicitly declared by the user.
* - Patch `setProps` accumulates keys across calls (once declared, always controlled).
* - Snapshot `_setPropsSnapshot` replaces this set on every render so that only the
* props the user actually declared in JSX are treated as controlled.
* Widgets call `isControlled(key)` to avoid overwriting props the app owns.
*/
private _controlledProps: Set<string> = new Set();

private _needsRedraw: false | string = 'Initial render';
private _pickRequest: {
mode: string;
Expand All @@ -348,6 +357,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
private _lastPointerDownInfo: PickingInfo | null = null;

constructor(props: DeckProps<ViewsT>) {
const userProps = props; // capture before merging with defaultProps
// @ts-ignore views
this.props = {...defaultProps, ...props};
props = this.props;
Expand Down Expand Up @@ -402,7 +412,11 @@ export default class Deck<ViewsT extends ViewOrViews = null> {

this.animationLoop = this._createAnimationLoop(deviceOrPromise, props);

this.setProps(props);
// Mark only user-supplied keys as controlled; defaultProps keys are not user intent.
for (const key of Object.keys(userProps)) {
this._controlledProps.add(key);
}
this._applyProps(props);

// UNSAFE/experimental prop: only set at initialization to avoid performance hit
if (props._typedArrayManagerProps) {
Expand Down Expand Up @@ -447,8 +461,87 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
}
}

/** Partially update props */
/**
* Declarative patch update: describes the desired state of a subset of props.
* Every supplied key is permanently marked as user-controlled so that widgets
* know not to overwrite it. Unmentioned props are left as-is.
*/
setProps(props: DeckProps<ViewsT>): void {
for (const key of Object.keys(props)) {
this._controlledProps.add(key);
}
this._applyProps(props);
}

/**
* Declarative snapshot update for framework wrappers (React, Vue, etc.).
* Only the keys the user actually declared are passed — not framework defaults
* or wrapper-owned overrides. The controlled set is replaced rather than
* accumulated so it always reflects the current declaration.
*
* Props that were controlled last render but are absent this render are reset
* to their defaults, preserving the declarative contract (removing a prop from
* JSX clears it). Props the user never declared are left untouched so widgets
* can manage them freely.
* @internal
*/
_setPropsSnapshot(
userProps: Partial<DeckProps<ViewsT>>,
allProps: Partial<DeckProps<ViewsT>> = userProps
): void {
const newControlled = new Set(Object.keys(userProps));

// Keys that were user-controlled last render but aren't now — reset to default
// so that removing a prop from JSX behaves the same as setting it to its default.
// Write resets into allProps so the full apply below picks them up.
for (const key of this._controlledProps) {
if (!newControlled.has(key)) {
(allProps as any)[key] = (defaultProps as any)[key];
}
}

this._controlledProps = newControlled;
this._applyProps(allProps as DeckProps<ViewsT>);
}

/**
* Returns true if the given prop key was explicitly supplied by the user via
* `setProps` or JSX. Widgets should avoid writing to controlled props:
*
* ```ts
* if (!deck.isControlled('viewState')) {
* deck.setProps({viewState: nextViewState});
* }
* ```
*/
isControlled(key: keyof DeckProps): boolean {
return this._controlledProps.has(key as string);
}

/**
* Apply a partial props update from a widget. Does not mark any keys as
* user-controlled, so `isControlled` continues to reflect only user intent.
* Widgets must use this instead of `setProps` to avoid polluting the
* controlled-props set.
* @internal
*/
_setWidgetProps(props: Partial<DeckProps<ViewsT>>): void {
this._applyProps(props as DeckProps<ViewsT>);
}

/**
* Replace the controlled-props set with the given keys.
* Used by framework wrappers (React etc.) to establish which props the user
* explicitly declared right after the Deck instance is first created, before
* the `onLoad` callback fires.
* @internal
*/
_setControlledProps(keys: Iterable<string>): void {
this._controlledProps = new Set(keys);
}

/** @internal Apply a props update without changing the controlled-props set. */
private _applyProps(props: DeckProps<ViewsT>): void {
this.stats.get('setProps Time').timeStart();

if ('onLayerHover' in props) {
Expand Down Expand Up @@ -1150,7 +1243,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
});
this.widgetManager.addDefault(new TooltipWidget());

this.setProps(this.props);
this._applyProps(this.props);

this._updateCanvasSize();
this.props.onLoad();
Expand Down
11 changes: 11 additions & 0 deletions modules/core/src/lib/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Copyright (c) vis.gl contributors

import type Deck from './deck';
import type {DeckProps} from './deck';
import type Viewport from '../viewports/viewport';
import type {PickingInfo} from './picking/pick-info';
import type {MjolnirPointerEvent, MjolnirGestureEvent} from 'mjolnir.js';
Expand Down Expand Up @@ -112,6 +113,16 @@ export abstract class Widget<
this.deck?._onViewStateChange({viewId, viewState, interactionState: {}});
}

/**
* Apply a partial Deck props update from this widget without marking any keys
* as user-controlled. Use this instead of `deck.setProps` so that
* `deck.isControlled` continues to reflect only what the user declared.
*/
protected updateDeckProps(props: Partial<DeckProps>): void {
// @ts-ignore _setWidgetProps is internal
this.deck?._setWidgetProps(props);
}

// @note empty method calls have an overhead in V8 but it is very low, ~1ns

/**
Expand Down
8 changes: 8 additions & 0 deletions modules/mapbox/src/deck-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ export function drawLayer(
// This is the first layer drawn in this render cycle.
// Generate viewport from the current map state.
currentViewport = getViewport(deck, map, renderParameters);
if (!currentViewport) {
// Dimensions are 0 (e.g. canvas not yet sized); skip rendering this cycle.
return;
}
(deck.userData as UserData).currentViewport = currentViewport;
clearStack = true;
}
Expand Down Expand Up @@ -163,6 +167,10 @@ export function drawLayerGroup(
// This is the first layer drawn in this render cycle.
// Generate viewport from the current map state.
currentViewport = getViewport(deck, map, renderParameters);
if (!currentViewport) {
// Dimensions are 0 (e.g. canvas not yet sized); skip rendering this cycle.
return;
}
(deck.userData as UserData).currentViewport = currentViewport;
clearStack = true;
}
Expand Down
59 changes: 46 additions & 13 deletions modules/react/src/deckgl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ import type {DeckProps, View, Viewport} from '@deck.gl/core';

export type ViewOrViews = View | View[] | null;

// Props the wrapper always overrides regardless of what the user writes in JSX.
// These must never be counted as user-controlled signals for widgets.
const WRAPPER_OWNED_KEYS = new Set<string>([
'style',
'width',
'height',
'parent',
'canvas',
'_customRender',
'onViewStateChange',
'onInteractionStateChange'
]);

/* eslint-disable max-statements, accessor-pairs */
type DeckInstanceRef<ViewsT extends ViewOrViews> = {
deck?: Deck<ViewsT>;
Expand Down Expand Up @@ -160,40 +173,60 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
// the next animation frame.
// 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()
// `explicitProps` is what gets passed to core. It contains:
// - wrapper-owned props that always need to be applied (style, size, canvas, callbacks)
// - user-declared props, with layers/views replaced by their JSX-processed equivalents
// but only when the user actually declared them (or JSX children provided them).
// Undeclared props are omitted entirely so widgets can manage them without being stomped.
const hasExplicitLayers = 'layers' in props || jsxProps.hasJSXLayers;
const hasExplicitViews = 'views' in props || jsxProps.hasJSXViews;

const explicitProps: DeckProps<ViewsT> = {
...Object.fromEntries(Object.entries(props).filter(([k]) => !WRAPPER_OWNED_KEYS.has(k))),
// Wrapper-owned — always applied
style: null,
width: '100%',
height: '100%',
parent: containerRef.current,
canvas: canvasRef.current,
layers: jsxProps.layers,
views: jsxProps.views as ViewsT,
onViewStateChange: handleViewStateChange,
onInteractionStateChange: handleInteractionStateChange
};
onInteractionStateChange: handleInteractionStateChange,
// Layer/view props only when the user (or JSX children) declared them
...(hasExplicitLayers && {layers: jsxProps.layers}),
...(hasExplicitViews && {views: jsxProps.views as ViewsT})
} as DeckProps<ViewsT>;

// The defaultValue for _customRender is null, which would overwrite the definition
// of _customRender. Remove to avoid frequently redeclaring the method here.
delete forwardProps._customRender;
delete explicitProps._customRender;

if (thisRef.deck) {
thisRef.deck.setProps(forwardProps);
// Strip wrapper-owned keys before snapshotting so they are never counted
// as user-controlled. The full explicitProps (including wrapper-owned keys)
// is still applied via _applyProps inside _setPropsSnapshot.
const userProps = Object.fromEntries(
Object.entries(explicitProps).filter(([k]) => !WRAPPER_OWNED_KEYS.has(k))
) as Partial<DeckProps<ViewsT>>;
thisRef.deck._setPropsSnapshot(userProps, explicitProps);
}

return forwardProps;
return explicitProps;
}, [props]);

useEffect(() => {
const DeckClass = props.Deck || Deck;

thisRef.deck = createDeckInstance(thisRef, DeckClass, {
const deckPropsToInit = {
...deckProps,
parent: containerRef.current,
canvas: canvasRef.current
});
};
thisRef.deck = createDeckInstance(thisRef, DeckClass, deckPropsToInit);

// The constructor marks ALL passed keys as controlled. Correct this to only
// reflect the keys the user actually declared (excluding wrapper-owned keys).
const controlledKeys = Object.keys(deckPropsToInit).filter(k => !WRAPPER_OWNED_KEYS.has(k));
thisRef.deck._setControlledProps(controlledKeys);

return () => thisRef.deck?.finalize();
}, []);
Expand Down
10 changes: 9 additions & 1 deletion modules/react/src/utils/extract-jsx-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export default function extractJSXLayers({
children: React.ReactNode[];
layers: LayersList;
views: View | View[] | null;
hasJSXLayers: boolean;
hasJSXViews: boolean;
} {
const reactChildren: React.ReactNode[] = []; // extract real react elements (i.e. not deck.gl layers)
const jsxLayers: LayersList = []; // extracted layer from react children, will add to deck.gl layer array
Expand Down Expand Up @@ -117,7 +119,13 @@ export default function extractJSXLayers({
// Avoid modifying layers array if no JSX layers were found
layers = jsxLayers.length > 0 ? [...jsxLayers, ...layers] : layers;

return {layers, children: reactChildren, views};
return {
layers,
children: reactChildren,
views,
hasJSXLayers: jsxLayers.length > 0,
hasJSXViews: Object.keys(jsxViews).length > 0
};
}

function createLayer(LayerType: typeof Layer, reactProps: any): Layer {
Expand Down
41 changes: 41 additions & 0 deletions test/modules/core/lib/deck.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,47 @@ test('Deck#getView with multiple views', t => {
});
});

test('Deck#isControlled', t => {
const deck = new Deck({
device,
width: 1,
height: 1,
viewState: {longitude: 0, latitude: 0, zoom: 0},
layers: [],

onLoad: () => {
// Props declared at construction are controlled
t.ok(deck.isControlled('layers'), 'layers declared at construction is controlled');
t.notOk(deck.isControlled('views'), 'undeclared views is not controlled');

// setProps accumulates controlled keys
deck.setProps({views: [new MapView()]});
t.ok(deck.isControlled('views'), 'views controlled after setProps');

// _setWidgetProps does not mark props controlled
deck._setWidgetProps({widgets: []});
t.notOk(deck.isControlled('widgets'), '_setWidgetProps does not mark widgets controlled');

// _setPropsSnapshot replaces the controlled set
deck._setPropsSnapshot({layers: []});
t.ok(deck.isControlled('layers'), 'layers controlled after snapshot with layers');
t.notOk(
deck.isControlled('views'),
'views dropped from controlled set after snapshot without views'
);

// Dropped controlled key is reset to its default value
deck.setProps({views: [new MapView()]});
t.ok(deck.props.views?.length, 'views set via setProps');
deck._setPropsSnapshot({layers: []});
t.notOk(deck.props.views?.length, 'views reset to default when dropped from snapshot');

deck.finalize();
t.end();
}
});
});

test('Deck#props omitted are unchanged', async t => {
const layer = new ScatterplotLayer({
id: 'scatterplot-global-data',
Expand Down
Loading
Loading