diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 22b10fb16cb..52ee76f6122 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -324,6 +324,15 @@ export default class Deck { }; 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 = new Set(); + private _needsRedraw: false | string = 'Initial render'; private _pickRequest: { mode: string; @@ -348,6 +357,7 @@ export default class Deck { private _lastPointerDownInfo: PickingInfo | null = null; constructor(props: DeckProps) { + const userProps = props; // capture before merging with defaultProps // @ts-ignore views this.props = {...defaultProps, ...props}; props = this.props; @@ -402,7 +412,11 @@ export default class Deck { 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) { @@ -447,8 +461,87 @@ export default class Deck { } } - /** 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): 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>, + allProps: Partial> = 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); + } + + /** + * 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>): void { + this._applyProps(props as DeckProps); + } + + /** + * 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): void { + this._controlledProps = new Set(keys); + } + + /** @internal Apply a props update without changing the controlled-props set. */ + private _applyProps(props: DeckProps): void { this.stats.get('setProps Time').timeStart(); if ('onLayerHover' in props) { @@ -1150,7 +1243,7 @@ export default class Deck { }); this.widgetManager.addDefault(new TooltipWidget()); - this.setProps(this.props); + this._applyProps(this.props); this._updateCanvasSize(); this.props.onLoad(); diff --git a/modules/core/src/lib/widget.ts b/modules/core/src/lib/widget.ts index 0aa74be2914..b0dc3bfc260 100644 --- a/modules/core/src/lib/widget.ts +++ b/modules/core/src/lib/widget.ts @@ -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'; @@ -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): 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 /** diff --git a/modules/mapbox/src/deck-utils.ts b/modules/mapbox/src/deck-utils.ts index 7ad9d017989..9619e575cac 100644 --- a/modules/mapbox/src/deck-utils.ts +++ b/modules/mapbox/src/deck-utils.ts @@ -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; } @@ -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; } diff --git a/modules/react/src/deckgl.ts b/modules/react/src/deckgl.ts index 527e96c50b6..d9e1a94b329 100644 --- a/modules/react/src/deckgl.ts +++ b/modules/react/src/deckgl.ts @@ -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([ + 'style', + 'width', + 'height', + 'parent', + 'canvas', + '_customRender', + 'onViewStateChange', + 'onInteractionStateChange' +]); + /* eslint-disable max-statements, accessor-pairs */ type DeckInstanceRef = { deck?: Deck; @@ -160,40 +173,60 @@ function DeckGLWithRef( // the next animation frame. // Needs to be called both from initial mount, and when new props are received const deckProps = useMemo(() => { - const forwardProps: DeckProps = { - 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 = { + ...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; // 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>; + 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(); }, []); diff --git a/modules/react/src/utils/extract-jsx-layers.ts b/modules/react/src/utils/extract-jsx-layers.ts index 1433db3435a..d1c352af895 100644 --- a/modules/react/src/utils/extract-jsx-layers.ts +++ b/modules/react/src/utils/extract-jsx-layers.ts @@ -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 @@ -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 { diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 3222baa36eb..424e9711641 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -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', diff --git a/test/modules/react/deckgl.spec.ts b/test/modules/react/deckgl.spec.ts index a245db56b1c..9c6730abf69 100644 --- a/test/modules/react/deckgl.spec.ts +++ b/test/modules/react/deckgl.spec.ts @@ -8,7 +8,7 @@ import {createElement, createRef} from 'react'; import {createRoot} from 'react-dom/client'; import {act} from 'react-dom/test-utils'; -import {DeckGL, Layer, Widget} from 'deck.gl'; +import {DeckGL, Layer, Widget, MapView} from 'deck.gl'; import {type WidgetProps, type WidgetPlacement} from '@deck.gl/core'; import {gl} from '@deck.gl/test-utils'; @@ -163,3 +163,155 @@ test('DeckGL#props omitted are reset', t => { }); t.ok(ref.current, 'DeckGL overlay is rendered.'); }); + +// A widget that takes ownership of `views` via updateDeckProps on mount. +class ViewManagingWidget extends Widget { + placement: WidgetPlacement = 'top-left'; + className = 'deck-view-managing-widget'; + views: MapView[]; + + constructor(views: MapView[], props: WidgetProps = {}) { + super(props); + this.views = views; + } + + onAdd({deck}: {deck: any}): void { + if (!deck.isControlled('views')) { + this.updateDeckProps({views: this.views}); + } + } + + onRenderHTML(rootElement: HTMLElement): void {} +} + +test('DeckGL#widget-managed views survive re-render', t => { + const ref = createRef(); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + const widgetViews = [new MapView({id: 'widget-view'})]; + const widget = new ViewManagingWidget(widgetViews); + + act(() => { + root.render( + createElement(DeckGL, { + initialViewState: TEST_VIEW_STATE, + ref, + width: 100, + height: 100, + gl: getMockContext(), + widgets: [widget], + onLoad: () => { + const {deck} = ref.current; + t.notOk(deck.isControlled('views'), 'views is not user-controlled'); + t.ok(deck.props.views?.length, 'widget-set views are applied'); + + act(() => { + root.render( + createElement(DeckGL, { + ref, + width: 100, + height: 100, + gl: getMockContext(), + widgets: [widget], + onAfterRender: () => { + const {deck} = ref.current; + t.notOk( + deck.isControlled('views'), + 'views still not user-controlled after re-render' + ); + t.ok(deck.props.views?.length, 'widget-set views survive re-render'); + + root.render(null); + container.remove(); + t.end(); + } + }) + ); + }); + } + }) + ); + }); + t.ok(ref.current, 'DeckGL overlay is rendered.'); +}); + +test('DeckGL#user views override widget views', t => { + const ref = createRef(); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + const widgetViews = [new MapView({id: 'widget-view'})]; + const userViews = [new MapView({id: 'user-view'})]; + const widget = new ViewManagingWidget(widgetViews); + + act(() => { + root.render( + createElement(DeckGL, { + initialViewState: TEST_VIEW_STATE, + ref, + width: 100, + height: 100, + gl: getMockContext(), + views: userViews, + widgets: [widget], + onLoad: () => { + const {deck} = ref.current; + t.ok(deck.isControlled('views'), 'views is user-controlled'); + t.is( + deck.props.views?.[0]?.id, + 'user-view', + 'user-declared views are not overwritten by widget' + ); + + root.render(null); + container.remove(); + t.end(); + } + }) + ); + }); + t.ok(ref.current, 'DeckGL overlay is rendered.'); +}); + +test('DeckGL#wrapper-owned keys are not user-controlled', t => { + const ref = createRef(); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + createElement(DeckGL, { + initialViewState: TEST_VIEW_STATE, + ref, + width: 100, + height: 100, + gl: getMockContext(), + onLoad: () => { + const {deck} = ref.current; + t.notOk(deck.isControlled('style'), 'style is not user-controlled'); + t.notOk(deck.isControlled('width'), 'width is not user-controlled'); + t.notOk(deck.isControlled('height'), 'height is not user-controlled'); + t.notOk(deck.isControlled('parent'), 'parent is not user-controlled'); + t.notOk(deck.isControlled('canvas'), 'canvas is not user-controlled'); + t.notOk( + deck.isControlled('onViewStateChange'), + 'onViewStateChange is not user-controlled' + ); + t.notOk( + deck.isControlled('onInteractionStateChange'), + 'onInteractionStateChange is not user-controlled' + ); + + root.render(null); + container.remove(); + t.end(); + } + }) + ); + }); + t.ok(ref.current, 'DeckGL overlay is rendered.'); +});