From 9f0c4b8b8ab926fc8d802f992ee6909dd0cdfd7a Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 16 Apr 2026 06:22:34 -0400 Subject: [PATCH 01/21] chore(core) Adopt luma CanvasContext --- modules/core/src/lib/deck-picker.ts | 52 +++++++++++----------- modules/core/src/lib/deck.ts | 47 +++++++++++--------- modules/google-maps/src/utils.ts | 3 +- modules/mapbox/src/deck-utils.ts | 5 ++- test/modules/core/lib/deck.spec.ts | 67 +++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 51 deletions(-) diff --git a/modules/core/src/lib/deck-picker.ts b/modules/core/src/lib/deck-picker.ts index 5b48426c930..e2a33b08f8d 100644 --- a/modules/core/src/lib/deck-picker.ts +++ b/modules/core/src/lib/deck-picker.ts @@ -186,10 +186,10 @@ export default class DeckPicker { } } - // Resize it to current canvas size (this is a noop if size hasn't changed) - const {canvas} = this.device.getDefaultCanvasContext(); - this.pickingFBO?.resize({width: canvas.width, height: canvas.height}); - this.depthFBO?.resize({width: canvas.width, height: canvas.height}); + // Picking buffers must track the active drawing buffer, not raw canvas element dimensions. + const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize(); + this.pickingFBO?.resize({width, height}); + this.depthFBO?.resize({width, height}); } /** Preliminary filtering of the layers list. Skid picking pass if no layer is pickable. */ @@ -223,8 +223,9 @@ export default class DeckPicker { result: PickingInfo[]; emptyInfo: PickingInfo; }> { - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); + const canvasContext = this.device.getDefaultCanvasContext(); + // Picking starts in CSS pixels, so use the canvas context's current conversion ratio. + const pixelRatio = canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -239,9 +240,8 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -387,8 +387,9 @@ export default class DeckPicker { result: PickingInfo[]; emptyInfo: PickingInfo; } { - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); + const canvasContext = this.device.getDefaultCanvasContext(); + // Keep the sync picking path aligned with the same canvas context state used for drawing. + const pixelRatio = canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); @@ -403,9 +404,8 @@ export default class DeckPicker { // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const devicePixelRange = canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) @@ -556,19 +556,17 @@ export default class DeckPicker { this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); - // @ts-expect-error TODO - assuming WebGL context - const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const canvasContext = this.device.getDefaultCanvasContext(); + const pixelRatio = canvasContext.cssToDeviceRatio(); + const leftTop = canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - // @ts-expect-error TODO - assuming WebGL context - const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; @@ -663,19 +661,17 @@ export default class DeckPicker { this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates - // And compensate for pixelRatio - // @ts-expect-error TODO - assuming WebGL context - const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); - // @ts-expect-error TODO - assuming WebGL context - const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); + // And compensate for the context's current CSS-to-device ratio. + const canvasContext = this.device.getDefaultCanvasContext(); + const pixelRatio = canvasContext.cssToDeviceRatio(); + const leftTop = canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location - // @ts-expect-error TODO - assuming WebGL context - const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); + const rightBottom = canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index dae456644d8..a1bc19bb79a 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -405,11 +405,9 @@ export default class Deck { _cachePipelines: true, ...this.props.deviceProps, onResize: (canvasContext, info) => { - // Sync drawing buffer dimensions with externally-managed canvas - const {width, height} = canvasContext.canvas; - canvasContext.setDrawingBufferSize(width, height); - - this._needsRedraw = 'Canvas resized'; + // Attached contexts still emit resize through luma's CanvasContext. + // Deck only mirrors that state into viewport dimensions and redraw flags. + this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); } }); @@ -1054,15 +1052,20 @@ export default class Deck { } } - /** If canvas size has changed, reads out the new size and update */ - private _updateCanvasSize(): void { + /** + * Sync Deck viewport dimensions from the active canvas context. + * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. + */ + private _updateCanvasSize( + canvasContext: {getCSSSize(): [number, number]} | null = this.device?.getDefaultCanvasContext?.() || null + ): void { const {canvas} = this; - if (!canvas) { - return; - } - // Fallback to width/height when clientWidth/clientHeight are undefined (OffscreenCanvas). - const newWidth = canvas.clientWidth ?? canvas.width; - const newHeight = canvas.clientHeight ?? canvas.height; + const [newWidth, newHeight] = canvasContext + // The canvas context owns the authoritative CSS size after resize/DPR observation. + ? canvasContext.getCSSSize() + : // Fallback to width/height when there is no default canvas context available yet. + [canvas?.clientWidth ?? canvas?.width ?? 0, canvas?.clientHeight ?? canvas?.height ?? 0]; + if (newWidth !== this.width || newHeight !== this.height) { // @ts-expect-error private assign to read-only property this.width = newWidth; @@ -1075,6 +1078,12 @@ export default class Deck { } } + private _onCanvasContextResize(canvasContext: {getCSSSize(): [number, number]}): void { + // luma owns resize detection; Deck reacts by invalidating redraw and updating view state. + this._needsRedraw = 'Canvas resized'; + this._updateCanvasSize(canvasContext); + } + private _createAnimationLoop( deviceOrPromise: Device | Promise, props: DeckProps @@ -1149,10 +1158,9 @@ export default class Deck { autoResize: true }, onResize: (canvasContext, info) => { - // Set redraw flag when luma.gl's CanvasContext detects a resize - // This restores pre-9.2 behavior where resize automatically triggered redraws - this._needsRedraw = 'Canvas resized'; - // Call user's onResize if provided + // Deck-created canvases follow the same contract as attached canvases: + // luma updates canvas state, Deck updates viewport bookkeeping and callbacks. + this._onCanvasContextResize(canvasContext); userOnResize?.(canvasContext, info); } }); @@ -1383,7 +1391,8 @@ export default class Deck { this.setProps(this.props); - this._updateCanvasSize(); + // Seed the initial Deck width/height from the current canvas context before onLoad fires. + this._updateCanvasSize(this.device.getDefaultCanvasContext()); this.props.onLoad(); } @@ -1447,8 +1456,6 @@ export default class Deck { } } - this._updateCanvasSize(); - this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) diff --git a/modules/google-maps/src/utils.ts b/modules/google-maps/src/utils.ts index dfadc394ed4..ed999ddf84f 100644 --- a/modules/google-maps/src/utils.ts +++ b/modules/google-maps/src/utils.ts @@ -46,7 +46,8 @@ export function createDeckInstance( const newDeck = new Deck({ ...props, - // Default to true for high-DPI displays, but allow user override + // The basemap owns the shared canvas in interleaved mode; Deck only forwards the preferred DPR. + // In non-interleaved mode this still feeds the luma canvas context that Deck creates. useDevicePixels: props.useDevicePixels ?? true, style: props.interleaved ? null : {pointerEvents: 'none'}, parent: getContainer(overlay, props.style), diff --git a/modules/mapbox/src/deck-utils.ts b/modules/mapbox/src/deck-utils.ts index bc49aa7a4a3..9daf4411a51 100644 --- a/modules/mapbox/src/deck-utils.ts +++ b/modules/mapbox/src/deck-utils.ts @@ -51,8 +51,9 @@ export function getDeckInstance({ }; deckProps.views ||= getDefaultView(map); - // deck is using the WebGLContext created by mapbox, - // block deck from setting the canvas size, and use the map's viewState to drive deck. + // deck is using the WebGLContext created by mapbox. + // The map and its attached luma canvas context own canvas sizing and DPR state here. + // Deck only follows view state and avoids trying to size the shared canvas itself. Object.assign(deckProps, { width: null, height: null, diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index e4c0d9a7280..47925f1608a 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -134,6 +134,73 @@ test('Deck#abort', async () => { console.log('Deck initialization aborted'); }); +test('Deck#canvas context resize drives Deck dimensions', async () => { + const resizeEvents: Array<{width: number; height: number}> = []; + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [], + onResize: dimensions => resizeEvents.push(dimensions) + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); + const nextSize: [number, number] = [17, 19]; + + try { + canvasContext.getCSSSize = () => nextSize; + resizeEvents.length = 0; + + // Call the internal resize hook directly so the test verifies Deck's reaction to luma state. + // @ts-expect-error testing private resize hook + deck._onCanvasContextResize(canvasContext); + + expect(deck.width, 'Deck width comes from canvas context CSS size').toBe(nextSize[0]); + expect(deck.height, 'Deck height comes from canvas context CSS size').toBe(nextSize[1]); + expect(resizeEvents, 'Deck onResize fires from canvas context resize').toEqual([ + {width: nextSize[0], height: nextSize[1]} + ]); + expect(deck.needsRedraw(), 'resize invalidates redraw').toBeTruthy(); + } finally { + canvasContext.getCSSSize = originalGetCSSSize; + deck.finalize(); + } +}); + +test('Deck#useDevicePixels forwards to canvas context', async () => { + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [] + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const initialUseDevicePixels = canvasContext.props.useDevicePixels; + + try { + // Deck.setProps should only forward the preference into luma's canvas context. + deck.setProps({useDevicePixels: false}); + expect(canvasContext.props.useDevicePixels, 'canvas context useDevicePixels updated').toBe( + false + ); + + // Numeric overrides should flow through unchanged so luma can size the drawing buffer. + deck.setProps({useDevicePixels: 2}); + expect(canvasContext.props.useDevicePixels, 'numeric DPR override is forwarded').toBe(2); + } finally { + canvasContext.setProps({useDevicePixels: initialUseDevicePixels}); + deck.finalize(); + } +}); + test('Deck#no views', async () => { await new Promise((resolve, reject) => { const deck = new Deck({ From 0000d5abaf16ad3a0df617f1810c05b917b1a3ee Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 16 Apr 2026 08:11:11 -0400 Subject: [PATCH 02/21] fix --- modules/core/src/lib/deck.ts | 19 ++++++++++++--- test/modules/core/lib/deck.spec.ts | 37 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index a1bc19bb79a..e91eb7adb81 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -346,6 +346,7 @@ export default class Deck { private _metricsCounter: number = 0; private _hoverPickSequence: number = 0; private _pointerDownPickSequence: number = 0; + private _pollCanvasContextSize: boolean = false; private _needsRedraw: false | string = 'Initial render'; private _pickRequest: { @@ -386,6 +387,9 @@ export default class Deck { // See if we already have a device if (props.device) { this.device = props.device; + // External Device ownership means Deck cannot wrap luma's onResize callback. + // Keep a render-loop poll so viewport dimensions still follow CanvasContext state. + this._pollCanvasContextSize = true; } let deviceOrPromise: Device | Promise | null = this.device; @@ -1057,12 +1061,14 @@ export default class Deck { * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. */ private _updateCanvasSize( - canvasContext: {getCSSSize(): [number, number]} | null = this.device?.getDefaultCanvasContext?.() || null + canvasContext: { + getCSSSize(): [number, number]; + } | null = this.device?.getDefaultCanvasContext?.() || null ): void { const {canvas} = this; const [newWidth, newHeight] = canvasContext - // The canvas context owns the authoritative CSS size after resize/DPR observation. - ? canvasContext.getCSSSize() + ? // The canvas context owns the authoritative CSS size after resize/DPR observation. + canvasContext.getCSSSize() : // Fallback to width/height when there is no default canvas context available yet. [canvas?.clientWidth ?? canvas?.width ?? 0, canvas?.clientHeight ?? canvas?.height ?? 0]; @@ -1456,6 +1462,13 @@ export default class Deck { } } + if (this._pollCanvasContextSize) { + // Callers that hand Deck an existing Device keep luma's CanvasContext as the source + // of truth, but Deck does not own that context's onResize wiring. Poll the context + // once per frame so width/height stay in sync without falling back to DOM reads. + this._updateCanvasSize(); + } + this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 47925f1608a..0d2dc02a7a9 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -201,6 +201,43 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { } }); +test('Deck#render frame syncs provided device canvas context size', async () => { + const resizeEvents: Array<{width: number; height: number}> = []; + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [], + onResize: dimensions => resizeEvents.push(dimensions) + }); + + await waitForRender(deck); + + const canvasContext = device.getDefaultCanvasContext(); + const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); + const nextSize: [number, number] = [23, 29]; + + try { + canvasContext.getCSSSize = () => nextSize; + resizeEvents.length = 0; + + // Provided Device instances do not route luma's onResize callback through Deck. + // The render loop still refreshes dimensions from CanvasContext so view state stays current. + // @ts-expect-error testing private render loop + deck._onRenderFrame(); + + expect(deck.width, 'Deck width is refreshed during render frame').toBe(nextSize[0]); + expect(deck.height, 'Deck height is refreshed during render frame').toBe(nextSize[1]); + expect(resizeEvents, 'Deck onResize fires from render-frame canvas context sync').toEqual([ + {width: nextSize[0], height: nextSize[1]} + ]); + } finally { + canvasContext.getCSSSize = originalGetCSSSize; + deck.finalize(); + } +}); + test('Deck#no views', async () => { await new Promise((resolve, reject) => { const deck = new Deck({ From 8076215f2f2cf61878502281b5a7e8ce3e6d5877 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 8 May 2026 09:00:17 -0400 Subject: [PATCH 03/21] chore(core): route canvas sizing through CanvasContext --- modules/core/src/lib/deck-picker.ts | 26 +++---- modules/core/src/lib/deck.ts | 85 ++++++++++++++--------- test/modules/core/lib/deck-picker.spec.ts | 36 ++++++++++ 3 files changed, 102 insertions(+), 45 deletions(-) diff --git a/modules/core/src/lib/deck-picker.ts b/modules/core/src/lib/deck-picker.ts index e2a33b08f8d..48d291c8f4c 100644 --- a/modules/core/src/lib/deck-picker.ts +++ b/modules/core/src/lib/deck-picker.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {Buffer, Texture} from '@luma.gl/core'; -import type {Device} from '@luma.gl/core'; +import type {CanvasContext, Device} from '@luma.gl/core'; import PickLayersPass, {PickingColorDecoder} from '../passes/pick-layers-pass'; import log from '../utils/log'; import {getClosestObject, getUniqueObjects, PickedPixel} from './picking/query-object'; @@ -44,6 +44,7 @@ type PickOperationContext = { layers: Layer[]; views: Record; viewports: Viewport[]; + canvasContext?: CanvasContext; onViewportActive: (viewport: Viewport) => void; effects: Effect[]; }; @@ -157,7 +158,7 @@ export default class DeckPicker { // Private /** Ensures that picking framebuffer exists and matches the canvas size */ - _resizeBuffer() { + _resizeBuffer(canvasContext: CanvasContext = this.device.getDefaultCanvasContext()) { // Create a frame buffer if not already available if (!this.pickingFBO) { const pickingColorTexture = this.device.createTexture({ @@ -186,8 +187,9 @@ export default class DeckPicker { } } - // Picking buffers must track the active drawing buffer, not raw canvas element dimensions. - const [width, height] = this.device.getDefaultCanvasContext().getDrawingBufferSize(); + // Picking renders in drawing-buffer pixels. DPR and useDevicePixels can make that size + // differ from the canvas/CSS size used for viewport bookkeeping. + const [width, height] = canvasContext.getDrawingBufferSize(); this.pickingFBO?.resize({width, height}); this.depthFBO?.resize({width, height}); } @@ -217,13 +219,13 @@ export default class DeckPicker { depth = 1, mode = 'query', unproject3D, + canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects }: PickByPointOptions & PickOperationContext): Promise<{ result: PickingInfo[]; emptyInfo: PickingInfo; }> { - const canvasContext = this.device.getDefaultCanvasContext(); // Picking starts in CSS pixels, so use the canvas context's current conversion ratio. const pixelRatio = canvasContext.cssToDeviceRatio(); @@ -236,7 +238,7 @@ export default class DeckPicker { }; } - this._resizeBuffer(); + this._resizeBuffer(canvasContext); // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] @@ -381,13 +383,13 @@ export default class DeckPicker { depth = 1, mode = 'query', unproject3D, + canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects }: PickByPointOptions & PickOperationContext): { result: PickingInfo[]; emptyInfo: PickingInfo; } { - const canvasContext = this.device.getDefaultCanvasContext(); // Keep the sync picking path aligned with the same canvas context state used for drawing. const pixelRatio = canvasContext.cssToDeviceRatio(); @@ -400,7 +402,7 @@ export default class DeckPicker { }; } - this._resizeBuffer(); + this._resizeBuffer(canvasContext); // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] @@ -544,6 +546,7 @@ export default class DeckPicker { height = 1, mode = 'query', maxObjects = null, + canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects }: PickByRectOptions & PickOperationContext): Promise { @@ -553,11 +556,10 @@ export default class DeckPicker { return []; } - this._resizeBuffer(); + this._resizeBuffer(canvasContext); // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for the context's current CSS-to-device ratio. - const canvasContext = this.device.getDefaultCanvasContext(); const pixelRatio = canvasContext.cssToDeviceRatio(); const leftTop = canvasContext.cssToDevicePixels([x, y], true); @@ -649,6 +651,7 @@ export default class DeckPicker { height = 1, mode = 'query', maxObjects = null, + canvasContext = this.device.getDefaultCanvasContext(), onViewportActive, effects }: PickByRectOptions & PickOperationContext): PickingInfo[] { @@ -658,11 +661,10 @@ export default class DeckPicker { return []; } - this._resizeBuffer(); + this._resizeBuffer(canvasContext); // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for the context's current CSS-to-device ratio. - const canvasContext = this.device.getDefaultCanvasContext(); const pixelRatio = canvasContext.cssToDeviceRatio(); const leftTop = canvasContext.cssToDevicePixels([x, y], true); diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 10e06848257..3819b7c7f12 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -21,7 +21,14 @@ import {webgl2Adapter} from '@luma.gl/webgl'; import {GL} from '@luma.gl/webgl/constants'; import {Timeline} from '@luma.gl/engine'; import {AnimationLoop} from '@luma.gl/engine'; -import type {CanvasContextProps, Device, DeviceProps, Framebuffer, Parameters} from '@luma.gl/core'; +import type { + CanvasContext, + CanvasContextProps, + Device, + DeviceProps, + Framebuffer, + Parameters +} from '@luma.gl/core'; import type {ShaderModule} from '@luma.gl/shadertools'; import {Stats} from '@probe.gl/stats'; @@ -313,6 +320,11 @@ export default class Deck { protected widgetManager: WidgetManager | null = null; protected tooltip: TooltipWidget | null = null; protected animationLoop: AnimationLoop | null = null; + private _canvasContext: CanvasContext | null = null; + // External Device instances are created before Deck can wrap luma's onResize callback. + // Poll their CanvasContext during render frames instead of mutating user-owned device props. + // TODO: Replace this special case if luma exposes CanvasContext resize listeners. + private _externalCanvasContext: CanvasContext | null = null; /** Internal view state if no callback is supplied */ protected viewState: ViewStateObject | null; @@ -346,7 +358,6 @@ export default class Deck { private _metricsCounter: number = 0; private _hoverPickSequence: number = 0; private _pointerDownPickSequence: number = 0; - private _pollCanvasContextSize: boolean = false; private _needsRedraw: false | string = 'Initial render'; private _pickRequest: { @@ -387,9 +398,7 @@ export default class Deck { // See if we already have a device if (props.device) { this.device = props.device; - // External Device ownership means Deck cannot wrap luma's onResize callback. - // Keep a render-loop poll so viewport dimensions still follow CanvasContext state. - this._pollCanvasContextSize = true; + this._setCanvasContext(props.device.getDefaultCanvasContext(), {external: true}); } let deviceOrPromise: Device | Promise | null = this.device; @@ -470,6 +479,8 @@ export default class Deck { this.canvas.parentElement?.removeChild(this.canvas); this.canvas = null; } + this._canvasContext = null; + this._externalCanvasContext = null; } /** Partially update props */ @@ -513,8 +524,9 @@ export default class Deck { }); if (props.device && props.device.id !== this.device?.id) { + const canvasContext = props.device.getDefaultCanvasContext(); this.animationLoop?.stop(); - if (this.canvas !== props.device.canvasContext?.canvas) { + if (this.canvas !== canvasContext.canvas) { // remove old canvas if new one being used and de-register events // TODO (ck): We might not own this canvas depending it's source, so removing it from the // DOM here might be a bit unexpected but it should be ok for most users. @@ -525,6 +537,8 @@ export default class Deck { this.canvas = null; } + this._setCanvasContext(canvasContext, {external: true}); + log.log(`recreating animation loop for new device! id=${props.device.id}`)(); this.animationLoop = this._createAnimationLoop(props.device, props); @@ -534,8 +548,8 @@ export default class Deck { // Update the animation loop this.animationLoop?.setProps(resolvedProps); - if (props.useDevicePixels !== undefined && this.device?.canvasContext?.setProps) { - this.device.canvasContext.setProps({useDevicePixels: props.useDevicePixels}); + if (props.useDevicePixels !== undefined && this._canvasContext?.setProps) { + this._canvasContext.setProps({useDevicePixels: props.useDevicePixels}); } // If initialized, update sub manager props @@ -956,7 +970,8 @@ export default class Deck { viewports: this.getViewports(opts), onViewportActive: this.layerManager!.activateViewport, effects: this.effectManager!.getEffects(), - ...opts + ...opts, + canvasContext: this._canvasContext || undefined }); stats.get(statKey).timeEnd(); @@ -997,7 +1012,8 @@ export default class Deck { viewports: this.getViewports(opts), onViewportActive: this.layerManager!.activateViewport, effects: this.effectManager!.getEffects(), - ...opts + ...opts, + canvasContext: this._canvasContext || undefined }); stats.get(statKey).timeEnd(); @@ -1036,6 +1052,15 @@ export default class Deck { return canvas; } + private _setCanvasContext(canvasContext: CanvasContext, opts: {external?: boolean} = {}): void { + this._canvasContext = canvasContext; + this._externalCanvasContext = opts.external ? canvasContext : null; + + if ('style' in canvasContext.canvas) { + this.canvas = canvasContext.canvas; + } + } + /** Updates canvas width and/or height, if provided as props */ private _setCanvasSize(props: Required>): void { if (!this.canvas) { @@ -1060,11 +1085,7 @@ export default class Deck { * Sync Deck viewport dimensions from the active canvas context. * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. */ - private _updateCanvasSize( - canvasContext: { - getCSSSize(): [number, number]; - } | null = this.device?.getDefaultCanvasContext?.() || null - ): void { + private _updateCanvasSize(canvasContext: CanvasContext | null = this._canvasContext): void { const {canvas} = this; const [newWidth, newHeight] = canvasContext ? // The canvas context owns the authoritative CSS size after resize/DPR observation. @@ -1084,7 +1105,7 @@ export default class Deck { } } - private _onCanvasContextResize(canvasContext: {getCSSSize(): [number, number]}): void { + private _onCanvasContextResize(canvasContext: CanvasContext): void { // luma owns resize detection; Deck reacts by invalidating redraw and updating view state. this._needsRedraw = 'Canvas resized'; this._updateCanvasSize(canvasContext); @@ -1289,20 +1310,18 @@ export default class Deck { return; } - // if external context... - if (!this.canvas) { - this.canvas = this.device.canvasContext?.canvas as HTMLCanvasElement; + const canvasContext = this.device.getDefaultCanvasContext(); + this._setCanvasContext(canvasContext, {external: this.props.device === device}); - // external canvas may not be in DOM - if (!this.canvas.isConnected && this.props.parent) { - this.props.parent.insertBefore(this.canvas, this.props.parent.firstChild); - } - // TODO v9 - // ts-expect-error - Currently luma.gl v9 does not expose these options - // All WebGLDevice contexts are instrumented, but it seems the device - // should have a method to start state tracking even if not enabled? - // instrumentGLContext(this.device.gl, {enable: true, copyState: true}); + // external canvas may not be in DOM + if (this.canvas && !this.canvas.isConnected && this.props.parent) { + this.props.parent.insertBefore(this.canvas, this.props.parent.firstChild); } + // TODO v9 + // ts-expect-error - Currently luma.gl v9 does not expose these options + // All WebGLDevice contexts are instrumented, but it seems the device + // should have a method to start state tracking even if not enabled? + // instrumentGLContext(this.device.gl, {enable: true, copyState: true}); if (this.device.type === 'webgl') { this.device.setParametersWebGL({ @@ -1398,7 +1417,7 @@ export default class Deck { this.setProps(this.props); // Seed the initial Deck width/height from the current canvas context before onLoad fires. - this._updateCanvasSize(this.device.getDefaultCanvasContext()); + this._updateCanvasSize(this._canvasContext); this.props.onLoad(); } @@ -1462,11 +1481,11 @@ export default class Deck { } } - if (this._pollCanvasContextSize) { + if (this._externalCanvasContext) { // Callers that hand Deck an existing Device keep luma's CanvasContext as the source - // of truth, but Deck does not own that context's onResize wiring. Poll the context - // once per frame so width/height stay in sync without falling back to DOM reads. - this._updateCanvasSize(); + // of truth, but Deck does not own that context's onResize wiring. Internally created + // and attached-gl devices update through luma's callback instead, avoiding this poll. + this._updateCanvasSize(this._externalCanvasContext); } this._updateCursor(); diff --git a/test/modules/core/lib/deck-picker.spec.ts b/test/modules/core/lib/deck-picker.spec.ts index 35be9e3bd79..066c08e14f9 100644 --- a/test/modules/core/lib/deck-picker.spec.ts +++ b/test/modules/core/lib/deck-picker.spec.ts @@ -47,6 +47,42 @@ test('DeckPicker#getPickingRect', () => { } }); +test('DeckPicker#_resizeBuffer uses drawing buffer size', () => { + const deckPicker = new DeckPicker(device); + const canvasContext = device.getDefaultCanvasContext(); + const originalGetDrawingBufferSize = canvasContext.getDrawingBufferSize.bind(canvasContext); + const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); + const drawingBufferSize: [number, number] = [37, 41]; + + try { + canvasContext.getCSSSize = () => [10, 11]; + canvasContext.getDrawingBufferSize = () => drawingBufferSize; + + deckPicker._resizeBuffer(canvasContext); + + expect(deckPicker.pickingFBO?.width, 'pickingFBO width follows drawing buffer').toBe( + drawingBufferSize[0] + ); + expect(deckPicker.pickingFBO?.height, 'pickingFBO height follows drawing buffer').toBe( + drawingBufferSize[1] + ); + expect( + deckPicker.depthFBO, + 'depthFBO is generated when float texture is renderable' + ).toBeTruthy(); + expect(deckPicker.depthFBO?.width, 'depthFBO width follows drawing buffer').toBe( + drawingBufferSize[0] + ); + expect(deckPicker.depthFBO?.height, 'depthFBO height follows drawing buffer').toBe( + drawingBufferSize[1] + ); + } finally { + canvasContext.getDrawingBufferSize = originalGetDrawingBufferSize; + canvasContext.getCSSSize = originalGetCSSSize; + deckPicker.finalize(); + } +}); + /* eslint-disable max-statements */ test('DeckPicker#pick empty', () => { const deckPicker = new DeckPicker(device); From 1a8486581954703ae03ee2d991549385f9ce13d5 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 11 May 2026 14:15:20 -0400 Subject: [PATCH 04/21] fix(google-maps): update overlay style container --- modules/google-maps/src/google-maps-overlay.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/google-maps/src/google-maps-overlay.ts b/modules/google-maps/src/google-maps-overlay.ts index ba9beaf13cd..bd2f357b1ad 100644 --- a/modules/google-maps/src/google-maps-overlay.ts +++ b/modules/google-maps/src/google-maps-overlay.ts @@ -100,9 +100,9 @@ export default class GoogleMapsOverlay { setProps(props: Partial): void { Object.assign(this.props, props); if (this._deck) { - const canvas = this._deck.getCanvas(); - if (props.style && canvas?.parentElement) { - const parentStyle = canvas.parentElement.style; + const parent = this._deck.props.parent || this._deck.getCanvas()?.parentElement; + if (props.style && parent) { + const parentStyle = parent.style; Object.assign(parentStyle, props.style); props.style = null; } From 99803a5f502e2ae188629ee006c59527f5da9981 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 11 May 2026 14:25:55 -0400 Subject: [PATCH 05/21] fix(core): sync attached gl drawing buffer on resize --- modules/core/src/lib/deck.ts | 18 +++++++---- test/modules/core/lib/deck.spec.ts | 48 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 3819b7c7f12..377bec18158 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -418,9 +418,9 @@ export default class Deck { _cachePipelines: true, ...this.props.deviceProps, onResize: (canvasContext, info) => { - // Attached contexts still emit resize through luma's CanvasContext. - // Deck only mirrors that state into viewport dimensions and redraw flags. - this._onCanvasContextResize(canvasContext); + // Attached contexts use luma CanvasContext for resize observation, but luma does not + // auto-resize drawing-buffer state for externally managed WebGL canvases. + this._onCanvasContextResize(canvasContext, {syncDrawingBuffer: true}); userOnResize?.(canvasContext, info); } }); @@ -1083,7 +1083,8 @@ export default class Deck { /** * Sync Deck viewport dimensions from the active canvas context. - * luma.gl owns resize observation, DPR tracking and drawing buffer sizing. + * luma.gl owns resize observation, DPR tracking and drawing buffer sizing for Deck-created + * canvases. Attached WebGL contexts still need Deck to mirror external drawing-buffer changes. */ private _updateCanvasSize(canvasContext: CanvasContext | null = this._canvasContext): void { const {canvas} = this; @@ -1105,7 +1106,14 @@ export default class Deck { } } - private _onCanvasContextResize(canvasContext: CanvasContext): void { + private _onCanvasContextResize( + canvasContext: CanvasContext, + opts: {syncDrawingBuffer?: boolean} = {} + ): void { + if (opts.syncDrawingBuffer) { + const {width, height} = canvasContext.canvas; + canvasContext.setDrawingBufferSize(width, height); + } // luma owns resize detection; Deck reacts by invalidating redraw and updating view state. this._needsRedraw = 'Canvas resized'; this._updateCanvasSize(canvasContext); diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 0d2dc02a7a9..86b330ee7b4 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -171,6 +171,54 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { } }); +webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async () => { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const gl = canvas.getContext('webgl2'); + expect(gl, 'WebGL2 context is created').toBeTruthy(); + + let userOnResizeCalls = 0; + const deck = new Deck({ + gl, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [], + deviceProps: { + onResize: () => userOnResizeCalls++ + } + }); + + await waitForRender(deck); + + const canvasContext = deck.device!.getDefaultCanvasContext(); + const originalSetDrawingBufferSize = + canvasContext.setDrawingBufferSize.bind(canvasContext); + const calls: Array<[number, number]> = []; + + try { + canvasContext.setDrawingBufferSize = (width: number, height: number) => { + calls.push([width, height]); + originalSetDrawingBufferSize(width, height); + }; + canvas.width = 37; + canvas.height = 41; + + deck.device!.props.onResize?.(canvasContext, {oldPixelSize: [1, 1]}); + + expect(calls, 'attached gl resize updates drawing buffer').toEqual([[37, 41]]); + expect(canvasContext.getDrawingBufferSize(), 'drawing buffer tracks external canvas').toEqual([ + 37, + 41 + ]); + expect(userOnResizeCalls, 'user onResize is preserved').toBe(1); + } finally { + canvasContext.setDrawingBufferSize = originalSetDrawingBufferSize; + deck.finalize(); + } +}); + test('Deck#useDevicePixels forwards to canvas context', async () => { const deck = new Deck({ device, From 8d8f32920a0e9b75314a700b80e8775d95e3cf2c Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 11 May 2026 15:51:40 -0400 Subject: [PATCH 06/21] prettier --- modules/react/src/utils/evaluate-children.ts | 2 +- modules/react/src/utils/extract-jsx-layers.ts | 8 ++------ modules/react/src/utils/position-children-under-views.ts | 4 ++-- test/modules/core/lib/deck.spec.ts | 6 ++---- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/modules/react/src/utils/evaluate-children.ts b/modules/react/src/utils/evaluate-children.ts index 4d0dafdef57..0179092cc8a 100644 --- a/modules/react/src/utils/evaluate-children.ts +++ b/modules/react/src/utils/evaluate-children.ts @@ -38,7 +38,7 @@ export function isComponent(child: React.ReactNode): child is React.ReactElement } function isReactMap(child: React.ReactElement): boolean { - return (child.props as any)?.mapStyle; + return child.props?.mapStyle; } function needsDeckGLViewProps(child: React.ReactElement): boolean { diff --git a/modules/react/src/utils/extract-jsx-layers.ts b/modules/react/src/utils/extract-jsx-layers.ts index 2ce574998b1..581f297b71d 100644 --- a/modules/react/src/utils/extract-jsx-layers.ts +++ b/modules/react/src/utils/extract-jsx-layers.ts @@ -52,7 +52,7 @@ function wrapInView(node: React.ReactNode | DeckGLRenderCallback): React.ReactNo } if (isComponent(node)) { if (node.type === React.Fragment) { - return wrapInView((node.props as any).children); + return wrapInView(node.props.children); } if (inheritsFrom(node.type, View)) { return node; @@ -92,11 +92,7 @@ export default function extractJSXLayers({ } // empty id => default view - if ( - inheritsFrom(ElementType, View) && - ElementType !== View && - (reactElement.props as any).id - ) { + if (inheritsFrom(ElementType, View) && ElementType !== View && reactElement.props.id) { // @ts-ignore Cannot instantiate an abstract class (View) const view = new ElementType(reactElement.props); jsxViews[view.id] = view; diff --git a/modules/react/src/utils/position-children-under-views.ts b/modules/react/src/utils/position-children-under-views.ts index e75353c2ae6..818a3524301 100644 --- a/modules/react/src/utils/position-children-under-views.ts +++ b/modules/react/src/utils/position-children-under-views.ts @@ -46,8 +46,8 @@ export default function positionChildrenUnderViews({ let viewChildren = child; if (isComponent(child) && inheritsFrom(child.type, View)) { - viewId = (child.props as any).id || defaultViewId; - viewChildren = (child.props as any).children; + viewId = child.props.id || defaultViewId; + viewChildren = child.props.children; } const viewport = viewManager.getViewport(viewId) as Viewport; diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 86b330ee7b4..e4a5fb67545 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -193,8 +193,7 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( await waitForRender(deck); const canvasContext = deck.device!.getDefaultCanvasContext(); - const originalSetDrawingBufferSize = - canvasContext.setDrawingBufferSize.bind(canvasContext); + const originalSetDrawingBufferSize = canvasContext.setDrawingBufferSize.bind(canvasContext); const calls: Array<[number, number]> = []; try { @@ -209,8 +208,7 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( expect(calls, 'attached gl resize updates drawing buffer').toEqual([[37, 41]]); expect(canvasContext.getDrawingBufferSize(), 'drawing buffer tracks external canvas').toEqual([ - 37, - 41 + 37, 41 ]); expect(userOnResizeCalls, 'user onResize is preserved').toBe(1); } finally { From e69f9f4092f9d0251b9ad1ad28082bb10272dff9 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 11 May 2026 16:21:38 -0400 Subject: [PATCH 07/21] docs: add agent merge guidance --- AGENTS.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..975e601273f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# Repository Guidance + +This file applies to the entire `deck.gl` repository. More specific `AGENTS.md` files in +subdirectories may add local guidance. + +## Setup Commands + +- Install dependencies from the repo root: `yarn` +- Build packages: `yarn build` +- Run lint: `yarn lint` +- Run all tests: `yarn test` +- Run headless tests: `yarn test-headless` +- Run render tests: `yarn test-render` +- Run browser tests: `yarn test-browser` +- Run website checks: `yarn test-website` +- Use the exact script names from `package.json`; do not substitute spaced forms such as + `yarn test headless`. + +## Before Committing + +- Run the most relevant tests for the changed packages, integrations, examples, or docs. +- Run `yarn lint` for JavaScript and TypeScript changes. If lint failures are unrelated existing + issues, call that out explicitly instead of hiding it. +- If dependencies or package metadata changed, run `yarn` in the repo root and include any + `yarn.lock` updates. +- Do not reformat files you are not otherwise changing. Keep formatting-only churn separate from + logic changes when practical. + +## Ready For Merge + +When asked to "get ready for merge", do a full merge-readiness pass: + +- Add or update TSDoc for every new or changed public class, function, method, property, and type. +- Update docs when behavior, public API, examples, or migration guidance changed. +- Keep upgrade guides focused on breaking changes and deprecations; put new-feature notes in the + appropriate docs or release notes. +- Run `yarn` in the repo root so workspace metadata and `yarn.lock` are up to date. +- Run `yarn build`. +- Run `yarn lint`. +- Run the relevant tests, typically one or more of `yarn test`, `yarn test-headless`, + `yarn test-render`, `yarn test-browser`, and `yarn test-website`. +- Prepare a copyable Markdown PR description based on the branch diff compared to `master`. Start + with the PR goals, then list the actual changes and validation. + +## Code Style + +- Prefer TypeScript and ES module syntax. +- Match the surrounding file style. In source files, use single quotes and semicolons. +- Never abbreviate variable names. Use camelCase for variables, functions, and fields; PascalCase + for types and classes; and CAPITAL_CASE for constants. +- Prefer verb-noun names for functions and methods. +- File names should be kebab-case unless an existing local convention differs. + +## Dependencies + +- Be conservative with new external dependencies. Add one only when it provides meaningful + capability, not just a small utility. +- Prefer vis.gl ecosystem packages when they fit the layering. Lower-level math or utility modules + should not depend on deck.gl. +- Prefer math.gl modules for math helpers. +- Avoid lodash-style dependencies for simple operations. + +## Investigation + +- Do not fix problems by adding caches. Investigate why the problem occurs and address the root + cause. From d4a057460dbb45b62ac0c9fb932646f0a822e7d5 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 11 May 2026 16:53:30 -0400 Subject: [PATCH 08/21] fix(react): preserve element props typing --- modules/react/src/utils/evaluate-children.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/react/src/utils/evaluate-children.ts b/modules/react/src/utils/evaluate-children.ts index 0179092cc8a..a67378cfad2 100644 --- a/modules/react/src/utils/evaluate-children.ts +++ b/modules/react/src/utils/evaluate-children.ts @@ -33,8 +33,8 @@ export default function evaluateChildren( return children; } -export function isComponent(child: React.ReactNode): child is React.ReactElement { - return (child && typeof child === 'object' && 'type' in child) || false; +export function isComponent(child: React.ReactNode): child is React.ReactElement { + return React.isValidElement(child); } function isReactMap(child: React.ReactElement): boolean { From f39d9c26e17af11eb969dc672856c8b22f4ac566 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 12:04:19 -0400 Subject: [PATCH 09/21] docs: expand merge readiness guidance --- AGENTS.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 975e601273f..6f0e3abf352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,17 +30,25 @@ subdirectories may add local guidance. When asked to "get ready for merge", do a full merge-readiness pass: -- Add or update TSDoc for every new or changed public class, function, method, property, and type. -- Update docs when behavior, public API, examples, or migration guidance changed. -- Keep upgrade guides focused on breaking changes and deprecations; put new-feature notes in the - appropriate docs or release notes. -- Run `yarn` in the repo root so workspace metadata and `yarn.lock` are up to date. -- Run `yarn build`. -- Run `yarn lint`. -- Run the relevant tests, typically one or more of `yarn test`, `yarn test-headless`, - `yarn test-render`, `yarn test-browser`, and `yarn test-website`. +- Audit the public API surface touched by the change. Add or update TSDoc for every new or changed + public class, function, method, property, and type. +- Do a documentation pass when behavior, public API, examples, or migration guidance changed. + Include relevant module docs, examples, sidebars, `docs/whats-new.md`, and upgrade or migration + guide content. +- Keep upgrade guides focused on breaking changes, removals, and deprecations. Put new-feature + notes in the appropriate module docs or release notes instead. +- Run `yarn` in the repo root so workspace metadata and `yarn.lock` are up to date, especially + after any `package.json` change. +- Run `yarn build` as the repo-wide type, declaration, and package build gate. +- Run `yarn lint` for the final lint and formatting gate, then review the resulting diff. +- Run the relevant tests for the changed packages, examples, integrations, and docs/website wiring. + Typical commands are `yarn test`, `yarn test-headless`, `yarn test-render`, `yarn test-browser`, + and `yarn test-website`. +- For website or docs changes, run the website check from the repo root with `yarn test-website`. - Prepare a copyable Markdown PR description based on the branch diff compared to `master`. Start with the PR goals, then list the actual changes and validation. +- In the final handoff, call out which merge-readiness gates passed, which were not run, and any + remaining risk or unrelated pre-existing failures. ## Code Style From cc3a2613fc0cca0b8a44f48bfdbb18f3d0dc5efe Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 12:17:13 -0400 Subject: [PATCH 10/21] fix --- modules/react/src/utils/evaluate-children.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/react/src/utils/evaluate-children.ts b/modules/react/src/utils/evaluate-children.ts index a67378cfad2..d709c87f9fc 100644 --- a/modules/react/src/utils/evaluate-children.ts +++ b/modules/react/src/utils/evaluate-children.ts @@ -33,15 +33,17 @@ export default function evaluateChildren( return children; } -export function isComponent(child: React.ReactNode): child is React.ReactElement { +export function isComponent( + child: React.ReactNode +): child is React.ReactElement> { return React.isValidElement(child); } -function isReactMap(child: React.ReactElement): boolean { +function isReactMap(child: React.ReactElement>): boolean { return child.props?.mapStyle; } -function needsDeckGLViewProps(child: React.ReactElement): boolean { +function needsDeckGLViewProps(child: React.ReactElement>): boolean { const componentClass = child.type; // @ts-expect-error deckGLViewProps is a custom hack defined on the constructor (nebula.gl) return componentClass && componentClass.deckGLViewProps; From b13922f9028fb228e234bfc7ca4f3efc4e42dc2d Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 12:39:58 -0400 Subject: [PATCH 11/21] test(core): isolate canvas context resize devices --- test/modules/core/lib/deck.spec.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index e4a5fb67545..daea199b8f5 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -6,6 +6,7 @@ import {test, expect} from 'vitest'; import {Deck, log, MapView} from '@deck.gl/core'; import {ScatterplotLayer} from '@deck.gl/layers'; import {FullscreenWidget} from '@deck.gl/widgets'; +import {WebGLDevice} from '@luma.gl/webgl'; import {device} from '@deck.gl/test-utils/vitest'; import {sleep} from './async-iterator-test-utils'; @@ -51,6 +52,10 @@ async function waitForRender(deck: Deck): Promise { const webglTest = device.type === 'webgl' ? test : test.skip; +function createIsolatedDevice(): WebGLDevice { + return new WebGLDevice({createCanvasContext: {width: 1, height: 1}}); +} + test('Deck#constructor', async () => { const callbacks = { onDeviceInitialized: 0, @@ -135,9 +140,10 @@ test('Deck#abort', async () => { }); test('Deck#canvas context resize drives Deck dimensions', async () => { + const testDevice = createIsolatedDevice(); const resizeEvents: Array<{width: number; height: number}> = []; const deck = new Deck({ - device, + device: testDevice, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -147,7 +153,7 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); + const canvasContext = testDevice.getDefaultCanvasContext(); const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [17, 19]; @@ -168,6 +174,7 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { } finally { canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); + testDevice.destroy(); } }); @@ -218,8 +225,9 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( }); test('Deck#useDevicePixels forwards to canvas context', async () => { + const testDevice = createIsolatedDevice(); const deck = new Deck({ - device, + device: testDevice, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -228,7 +236,7 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); + const canvasContext = testDevice.getDefaultCanvasContext(); const initialUseDevicePixels = canvasContext.props.useDevicePixels; try { @@ -244,13 +252,15 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { } finally { canvasContext.setProps({useDevicePixels: initialUseDevicePixels}); deck.finalize(); + testDevice.destroy(); } }); test('Deck#render frame syncs provided device canvas context size', async () => { + const testDevice = createIsolatedDevice(); const resizeEvents: Array<{width: number; height: number}> = []; const deck = new Deck({ - device, + device: testDevice, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -260,7 +270,7 @@ test('Deck#render frame syncs provided device canvas context size', async () => await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); + const canvasContext = testDevice.getDefaultCanvasContext(); const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [23, 29]; @@ -281,6 +291,7 @@ test('Deck#render frame syncs provided device canvas context size', async () => } finally { canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); + testDevice.destroy(); } }); From 9e26875a48ef363b04c5bcae1a012d01ff256848 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 12:52:30 -0400 Subject: [PATCH 12/21] Revert "test(core): isolate canvas context resize devices" This reverts commit b13922f9028fb228e234bfc7ca4f3efc4e42dc2d. --- test/modules/core/lib/deck.spec.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index daea199b8f5..e4a5fb67545 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -6,7 +6,6 @@ import {test, expect} from 'vitest'; import {Deck, log, MapView} from '@deck.gl/core'; import {ScatterplotLayer} from '@deck.gl/layers'; import {FullscreenWidget} from '@deck.gl/widgets'; -import {WebGLDevice} from '@luma.gl/webgl'; import {device} from '@deck.gl/test-utils/vitest'; import {sleep} from './async-iterator-test-utils'; @@ -52,10 +51,6 @@ async function waitForRender(deck: Deck): Promise { const webglTest = device.type === 'webgl' ? test : test.skip; -function createIsolatedDevice(): WebGLDevice { - return new WebGLDevice({createCanvasContext: {width: 1, height: 1}}); -} - test('Deck#constructor', async () => { const callbacks = { onDeviceInitialized: 0, @@ -140,10 +135,9 @@ test('Deck#abort', async () => { }); test('Deck#canvas context resize drives Deck dimensions', async () => { - const testDevice = createIsolatedDevice(); const resizeEvents: Array<{width: number; height: number}> = []; const deck = new Deck({ - device: testDevice, + device, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -153,7 +147,7 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { await waitForRender(deck); - const canvasContext = testDevice.getDefaultCanvasContext(); + const canvasContext = device.getDefaultCanvasContext(); const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [17, 19]; @@ -174,7 +168,6 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { } finally { canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); - testDevice.destroy(); } }); @@ -225,9 +218,8 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( }); test('Deck#useDevicePixels forwards to canvas context', async () => { - const testDevice = createIsolatedDevice(); const deck = new Deck({ - device: testDevice, + device, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -236,7 +228,7 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { await waitForRender(deck); - const canvasContext = testDevice.getDefaultCanvasContext(); + const canvasContext = device.getDefaultCanvasContext(); const initialUseDevicePixels = canvasContext.props.useDevicePixels; try { @@ -252,15 +244,13 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { } finally { canvasContext.setProps({useDevicePixels: initialUseDevicePixels}); deck.finalize(); - testDevice.destroy(); } }); test('Deck#render frame syncs provided device canvas context size', async () => { - const testDevice = createIsolatedDevice(); const resizeEvents: Array<{width: number; height: number}> = []; const deck = new Deck({ - device: testDevice, + device, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, @@ -270,7 +260,7 @@ test('Deck#render frame syncs provided device canvas context size', async () => await waitForRender(deck); - const canvasContext = testDevice.getDefaultCanvasContext(); + const canvasContext = device.getDefaultCanvasContext(); const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [23, 29]; @@ -291,7 +281,6 @@ test('Deck#render frame syncs provided device canvas context size', async () => } finally { canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); - testDevice.destroy(); } }); From f83ab0dd2f77b91a03e3d0a159f1455299e4df27 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 12:54:42 -0400 Subject: [PATCH 13/21] test(core): avoid shared canvas context mutation --- test/modules/core/lib/deck.spec.ts | 45 ++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index e4a5fb67545..2489e9db3b1 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -7,6 +7,7 @@ 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/vitest'; +import type {CanvasContext, CanvasContextProps} from '@luma.gl/core'; import {sleep} from './async-iterator-test-utils'; function createDeferred() { @@ -37,6 +38,20 @@ function createPointPickResult(props = {}) { }; } +function createMockCanvasContext(props: Partial = {}): CanvasContext { + const canvasContext = device.getDefaultCanvasContext(); + return { + canvas: canvasContext.canvas, + getCSSSize: canvasContext.getCSSSize.bind(canvasContext), + getDrawingBufferSize: canvasContext.getDrawingBufferSize.bind(canvasContext), + cssToDeviceRatio: canvasContext.cssToDeviceRatio.bind(canvasContext), + cssToDevicePixels: canvasContext.cssToDevicePixels.bind(canvasContext), + setProps: () => {}, + props: canvasContext.props as CanvasContextProps, + ...props + } as CanvasContext; +} + async function waitForRender(deck: Deck): Promise { await new Promise(resolve => { const onAfterRender = deck.props.onAfterRender; @@ -147,12 +162,10 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); - const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [17, 19]; + const canvasContext = createMockCanvasContext({getCSSSize: () => nextSize}); try { - canvasContext.getCSSSize = () => nextSize; resizeEvents.length = 0; // Call the internal resize hook directly so the test verifies Deck's reaction to luma state. @@ -166,7 +179,6 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { ]); expect(deck.needsRedraw(), 'resize invalidates redraw').toBeTruthy(); } finally { - canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); } }); @@ -228,21 +240,25 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); - const initialUseDevicePixels = canvasContext.props.useDevicePixels; + let useDevicePixels: boolean | number | undefined; + const canvasContext = createMockCanvasContext({ + setProps: (props: CanvasContextProps) => { + useDevicePixels = props.useDevicePixels; + } + }); try { + // @ts-expect-error testing private canvas context setter + deck._setCanvasContext(canvasContext); + // Deck.setProps should only forward the preference into luma's canvas context. deck.setProps({useDevicePixels: false}); - expect(canvasContext.props.useDevicePixels, 'canvas context useDevicePixels updated').toBe( - false - ); + expect(useDevicePixels, 'canvas context useDevicePixels updated').toBe(false); // Numeric overrides should flow through unchanged so luma can size the drawing buffer. deck.setProps({useDevicePixels: 2}); - expect(canvasContext.props.useDevicePixels, 'numeric DPR override is forwarded').toBe(2); + expect(useDevicePixels, 'numeric DPR override is forwarded').toBe(2); } finally { - canvasContext.setProps({useDevicePixels: initialUseDevicePixels}); deck.finalize(); } }); @@ -260,12 +276,12 @@ test('Deck#render frame syncs provided device canvas context size', async () => await waitForRender(deck); - const canvasContext = device.getDefaultCanvasContext(); - const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const nextSize: [number, number] = [23, 29]; + const canvasContext = createMockCanvasContext({getCSSSize: () => nextSize}); try { - canvasContext.getCSSSize = () => nextSize; + // @ts-expect-error testing private external canvas context field + deck._externalCanvasContext = canvasContext; resizeEvents.length = 0; // Provided Device instances do not route luma's onResize callback through Deck. @@ -279,7 +295,6 @@ test('Deck#render frame syncs provided device canvas context size', async () => {width: nextSize[0], height: nextSize[1]} ]); } finally { - canvasContext.getCSSSize = originalGetCSSSize; deck.finalize(); } }); From cdeac8f8c9941a61c7937a340635af0fced0596b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 13:05:17 -0400 Subject: [PATCH 14/21] test(core): avoid shared picker canvas context mutation --- test/modules/core/lib/deck-picker.spec.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/test/modules/core/lib/deck-picker.spec.ts b/test/modules/core/lib/deck-picker.spec.ts index 066c08e14f9..5def7fe3f82 100644 --- a/test/modules/core/lib/deck-picker.spec.ts +++ b/test/modules/core/lib/deck-picker.spec.ts @@ -7,6 +7,7 @@ import {LayerManager, MapView} from '@deck.gl/core'; import {ScatterplotLayer} from '@deck.gl/layers'; import DeckPicker from '@deck.gl/core/lib/deck-picker'; import {device} from '@deck.gl/test-utils/vitest'; +import type {CanvasContext} from '@luma.gl/core'; const DEVICE_RECT_TEST_CASES = [ { @@ -49,15 +50,13 @@ test('DeckPicker#getPickingRect', () => { test('DeckPicker#_resizeBuffer uses drawing buffer size', () => { const deckPicker = new DeckPicker(device); - const canvasContext = device.getDefaultCanvasContext(); - const originalGetDrawingBufferSize = canvasContext.getDrawingBufferSize.bind(canvasContext); - const originalGetCSSSize = canvasContext.getCSSSize.bind(canvasContext); const drawingBufferSize: [number, number] = [37, 41]; + const canvasContext = { + getCSSSize: () => [10, 11], + getDrawingBufferSize: () => drawingBufferSize + } as CanvasContext; try { - canvasContext.getCSSSize = () => [10, 11]; - canvasContext.getDrawingBufferSize = () => drawingBufferSize; - deckPicker._resizeBuffer(canvasContext); expect(deckPicker.pickingFBO?.width, 'pickingFBO width follows drawing buffer').toBe( @@ -77,8 +76,6 @@ test('DeckPicker#_resizeBuffer uses drawing buffer size', () => { drawingBufferSize[1] ); } finally { - canvasContext.getDrawingBufferSize = originalGetDrawingBufferSize; - canvasContext.getCSSSize = originalGetCSSSize; deckPicker.finalize(); } }); From 2e4697370a785f4f2e28381ff11bfaec92da224d Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 13:16:28 -0400 Subject: [PATCH 15/21] fix(core): only forward explicit device pixel ratio --- modules/core/src/lib/deck.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 377bec18158..c0f9ebab2c2 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -384,6 +384,7 @@ export default class Deck { private _lastPointerDownInfoPromise: Promise | null = null; constructor(props: DeckProps) { + const initialProps = props; // @ts-ignore views this.props = {...defaultProps, ...props}; props = this.props; @@ -433,7 +434,7 @@ export default class Deck { this.animationLoop = this._createAnimationLoop(deviceOrPromise, props); - this.setProps(props); + this.setProps(initialProps); // UNSAFE/experimental prop: only set at initialization to avoid performance hit if (props._typedArrayManagerProps) { From 90358e6c5dba54affbd4d115eb1b01839a3a96c2 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 13:26:36 -0400 Subject: [PATCH 16/21] fix(core): avoid replaying default props into canvas context --- modules/core/src/lib/deck.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index c0f9ebab2c2..9ed205d35fc 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -1423,7 +1423,7 @@ export default class Deck { }); this.widgetManager.addDefault(new TooltipWidget()); - this.setProps(this.props); + this.setProps({}); // Seed the initial Deck width/height from the current canvas context before onLoad fires. this._updateCanvasSize(this._canvasContext); From aa1cbb724f9c7ad294e544ba154e209b236188b4 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 13:37:48 -0400 Subject: [PATCH 17/21] fix(core): repopulate invalid picking color cache --- modules/core/src/lib/layer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/core/src/lib/layer.ts b/modules/core/src/lib/layer.ts index 63d2c6beef9..0de77327112 100644 --- a/modules/core/src/lib/layer.ts +++ b/modules/core/src/lib/layer.ts @@ -721,7 +721,8 @@ export default abstract class Layer extends Component< // @ts-ignore (TS2531) internalState is always defined when this method is called this.internalState.usesPickingColorCache = true; - if (cacheSize < numInstances) { + const isPickingColorCacheInvalid = numInstances > 0 && pickingColorCache[0] === 0; + if (cacheSize < numInstances || isPickingColorCacheInvalid) { if (numInstances > MAX_PICKING_COLOR_CACHE_SIZE) { log.warn( 'Layer has too many data objects. Picking might not be able to distinguish all objects.' @@ -737,7 +738,8 @@ export default abstract class Layer extends Component< // If the attribute is larger than the cache, resize the cache and populate the missing chunk const newCacheSize = Math.floor(pickingColorCache.length / 4); const pickingColor: [number, number, number] = [0, 0, 0]; - for (let i = cacheSize; i < newCacheSize; i++) { + const startIndex = isPickingColorCacheInvalid ? 0 : cacheSize; + for (let i = startIndex; i < newCacheSize; i++) { this.encodePickingColor(i, pickingColor); pickingColorCache[i * 4 + 0] = pickingColor[0]; pickingColorCache[i * 4 + 1] = pickingColor[1]; From 2c0070d1d9f5e424b078fbdab61736274a281826 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 13 May 2026 16:58:01 -0400 Subject: [PATCH 18/21] Address comments --- docs/api-reference/core/deck.md | 3 + modules/core/src/lib/deck.ts | 92 +++++++++++++++++------------- test/modules/core/lib/deck.spec.ts | 72 +++++++++++++++-------- 3 files changed, 104 insertions(+), 63 deletions(-) diff --git a/docs/api-reference/core/deck.md b/docs/api-reference/core/deck.md index cd22019ee6b..39d7d06ecad 100644 --- a/docs/api-reference/core/deck.md +++ b/docs/api-reference/core/deck.md @@ -52,6 +52,8 @@ The canvas to render into. Can be either a HTMLCanvasElement or the element id. luma.gl Device used to manage the application's connection with the GPU. Will be auto-created if not supplied. +When a `Device` is supplied, Deck does not destroy it when finalized. While the `Deck` instance is active, Deck owns the `device.props.onResize` callback for the active render canvas context; use `DeckProps.onResize` to observe Deck canvas resizes. + #### `deviceProps` ([DeviceProps](https://luma.gl/docs/api-reference/core/device#deviceprops) | [WebGLDeviceProps](https://luma.gl/docs/api-reference/webgl/#webgldeviceprops)) {#deviceprops} Options used for creating a new luma.gl GPU [Device](https://luma.gl/docs/api-reference/core/device). @@ -551,6 +553,7 @@ Receives arguments: * `size` - `width` (number) - the new width of the deck canvas, in client pixels - `height` (number) - the new height of the deck canvas, in client pixels +* `canvasContext` ([CanvasContext](https://luma.gl/docs/api-reference/core/canvas-context), optional) - the luma.gl canvas context that reported the resize #### `onBeforeRender` (Function) {#onbeforerender} diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 9ed205d35fc..9cff26cb0e9 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -197,7 +197,7 @@ export type DeckProps = { /** @deprecated Called once the WebGL context has been initiated. */ onWebGLInitialized?: (gl: WebGL2RenderingContext) => void; /** Called when the canvas resizes. */ - onResize?: (dimensions: {width: number; height: number}) => void; + onResize?: (dimensions: {width: number; height: number}, canvasContext?: CanvasContext) => void; /** Called when the user has interacted with the deck.gl canvas, e.g. using mouse, touch or keyboard. */ onViewStateChange?: >( params: ViewStateChangeParameters @@ -321,10 +321,11 @@ export default class Deck { protected tooltip: TooltipWidget | null = null; protected animationLoop: AnimationLoop | null = null; private _canvasContext: CanvasContext | null = null; - // External Device instances are created before Deck can wrap luma's onResize callback. - // Poll their CanvasContext during render frames instead of mutating user-owned device props. - // TODO: Replace this special case if luma exposes CanvasContext resize listeners. - private _externalCanvasContext: CanvasContext | null = null; + private _deviceResizeHandler: { + device: Device; + onResize: NonNullable; + syncDrawingBuffer: boolean; + } | null = null; /** Internal view state if no callback is supplied */ protected viewState: ViewStateObject | null; @@ -399,7 +400,7 @@ export default class Deck { // See if we already have a device if (props.device) { this.device = props.device; - this._setCanvasContext(props.device.getDefaultCanvasContext(), {external: true}); + this._setDeviceCanvasContext(props.device); } let deviceOrPromise: Device | Promise | null = this.device; @@ -409,21 +410,12 @@ export default class Deck { if (props.gl instanceof WebGLRenderingContext) { log.error('WebGL1 context not supported.')(); } - // Preserve user's callbacks and add resize handling - const userOnResize = this.props.deviceProps?.onResize; - deviceOrPromise = webgl2Adapter.attach(props.gl, { // Enable shader and pipeline caching for attached devices (matches _createDevice defaults) // Without this, interleaved mode (e.g., MapboxOverlay) creates new pipelines every frame _cacheShaders: true, _cachePipelines: true, - ...this.props.deviceProps, - onResize: (canvasContext, info) => { - // Attached contexts use luma CanvasContext for resize observation, but luma does not - // auto-resize drawing-buffer state for externally managed WebGL canvases. - this._onCanvasContextResize(canvasContext, {syncDrawingBuffer: true}); - userOnResize?.(canvasContext, info); - } + ...this.props.deviceProps }); } @@ -446,6 +438,8 @@ export default class Deck { /** Stop rendering and dispose all resources */ finalize() { + this._restoreDeviceResizeHandler(); + this.animationLoop?.stop(); this.animationLoop?.destroy(); this.animationLoop = null; @@ -481,7 +475,6 @@ export default class Deck { this.canvas = null; } this._canvasContext = null; - this._externalCanvasContext = null; } /** Partially update props */ @@ -538,7 +531,7 @@ export default class Deck { this.canvas = null; } - this._setCanvasContext(canvasContext, {external: true}); + this._setDeviceCanvasContext(props.device); log.log(`recreating animation loop for new device! id=${props.device.id}`)(); @@ -1053,15 +1046,51 @@ export default class Deck { return canvas; } - private _setCanvasContext(canvasContext: CanvasContext, opts: {external?: boolean} = {}): void { + private _setCanvasContext(canvasContext: CanvasContext): void { this._canvasContext = canvasContext; - this._externalCanvasContext = opts.external ? canvasContext : null; if ('style' in canvasContext.canvas) { this.canvas = canvasContext.canvas; } } + private _setDeviceCanvasContext(device: Device, opts: {syncDrawingBuffer?: boolean} = {}): void { + const canvasContext = device.getDefaultCanvasContext(); + this._setCanvasContext(canvasContext); + this._setDeviceResizeHandler(device, opts); + } + + private _setDeviceResizeHandler(device: Device, opts: {syncDrawingBuffer?: boolean} = {}): void { + const syncDrawingBuffer = Boolean(opts.syncDrawingBuffer); + if (this._deviceResizeHandler?.device === device) { + this._deviceResizeHandler.syncDrawingBuffer = syncDrawingBuffer; + return; + } + + this._restoreDeviceResizeHandler(); + + const onResize: NonNullable = canvasContext => { + if (canvasContext === this._canvasContext && this._canvasContext) { + // Deck owns resize handling for the active render CanvasContext. Applications should use + // DeckProps.onResize instead of the lower-level luma device callback while Deck is active. + this._onCanvasContextResize(this._canvasContext, { + syncDrawingBuffer: this._deviceResizeHandler?.syncDrawingBuffer + }); + } + }; + + device.props.onResize = onResize; + this._deviceResizeHandler = {device, onResize, syncDrawingBuffer}; + } + + private _restoreDeviceResizeHandler(): void { + const resizeHandler = this._deviceResizeHandler; + if (resizeHandler && resizeHandler.device.props?.onResize === resizeHandler.onResize) { + resizeHandler.device.props.onResize = noop; + } + this._deviceResizeHandler = null; + } + /** Updates canvas width and/or height, if provided as props */ private _setCanvasSize(props: Required>): void { if (!this.canvas) { @@ -1103,7 +1132,7 @@ export default class Deck { this.viewManager?.setProps({width: newWidth, height: newHeight}); // Make sure that any new layer gets initialized with the current viewport this.layerManager?.activateViewport(this.getViewports()[0]); - this.props.onResize({width: newWidth, height: newHeight}); + this.props.onResize({width: newWidth, height: newHeight}, canvasContext || undefined); } } @@ -1173,9 +1202,6 @@ export default class Deck { alphaMode: this.props.deviceProps?.type === 'webgpu' ? 'premultiplied' : undefined }; - // Preserve user's onResize callback - const userOnResize = this.props.deviceProps?.onResize; - // Create the "best" device supported from the registered adapters return luma.createDevice({ // luma by default throws if a device is already attached @@ -1192,12 +1218,6 @@ export default class Deck { canvas: this._createCanvas(props), useDevicePixels: this.props.useDevicePixels, autoResize: true - }, - onResize: (canvasContext, info) => { - // Deck-created canvases follow the same contract as attached canvases: - // luma updates canvas state, Deck updates viewport bookkeeping and callbacks. - this._onCanvasContextResize(canvasContext); - userOnResize?.(canvasContext, info); } }); } @@ -1319,8 +1339,9 @@ export default class Deck { return; } - const canvasContext = this.device.getDefaultCanvasContext(); - this._setCanvasContext(canvasContext, {external: this.props.device === device}); + this._setDeviceCanvasContext(device, { + syncDrawingBuffer: Boolean(this.props.gl && this.props.device !== device) + }); // external canvas may not be in DOM if (this.canvas && !this.canvas.isConnected && this.props.parent) { @@ -1490,13 +1511,6 @@ export default class Deck { } } - if (this._externalCanvasContext) { - // Callers that hand Deck an existing Device keep luma's CanvasContext as the source - // of truth, but Deck does not own that context's onResize wiring. Internally created - // and attached-gl devices update through luma's callback instead, avoiding this poll. - this._updateCanvasSize(this._externalCanvasContext); - } - this._updateCursor(); // Update layers if needed (e.g. some async prop has loaded) diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 2489e9db3b1..2e3702d1f9d 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -150,14 +150,17 @@ test('Deck#abort', async () => { }); test('Deck#canvas context resize drives Deck dimensions', async () => { - const resizeEvents: Array<{width: number; height: number}> = []; + const resizeEvents: Array<{ + dimensions: {width: number; height: number}; + canvasContext?: CanvasContext; + }> = []; const deck = new Deck({ device, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, layers: [], - onResize: dimensions => resizeEvents.push(dimensions) + onResize: (dimensions, canvasContext) => resizeEvents.push({dimensions, canvasContext}) }); await waitForRender(deck); @@ -174,9 +177,13 @@ test('Deck#canvas context resize drives Deck dimensions', async () => { expect(deck.width, 'Deck width comes from canvas context CSS size').toBe(nextSize[0]); expect(deck.height, 'Deck height comes from canvas context CSS size').toBe(nextSize[1]); - expect(resizeEvents, 'Deck onResize fires from canvas context resize').toEqual([ - {width: nextSize[0], height: nextSize[1]} - ]); + expect(resizeEvents[0]?.dimensions, 'Deck onResize fires from canvas context resize').toEqual({ + width: nextSize[0], + height: nextSize[1] + }); + expect(resizeEvents[0]?.canvasContext, 'Deck onResize receives canvas context').toBe( + canvasContext + ); expect(deck.needsRedraw(), 'resize invalidates redraw').toBeTruthy(); } finally { deck.finalize(); @@ -190,16 +197,17 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( const gl = canvas.getContext('webgl2'); expect(gl, 'WebGL2 context is created').toBeTruthy(); - let userOnResizeCalls = 0; + const resizeEvents: Array<{ + dimensions: {width: number; height: number}; + canvasContext?: CanvasContext; + }> = []; const deck = new Deck({ gl, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, layers: [], - deviceProps: { - onResize: () => userOnResizeCalls++ - } + onResize: (dimensions, canvasContext) => resizeEvents.push({dimensions, canvasContext}) }); await waitForRender(deck); @@ -215,6 +223,7 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( }; canvas.width = 37; canvas.height = 41; + resizeEvents.length = 0; deck.device!.props.onResize?.(canvasContext, {oldPixelSize: [1, 1]}); @@ -222,7 +231,8 @@ webglTest('Deck#attached gl resize syncs canvas context drawing buffer', async ( expect(canvasContext.getDrawingBufferSize(), 'drawing buffer tracks external canvas').toEqual([ 37, 41 ]); - expect(userOnResizeCalls, 'user onResize is preserved').toBe(1); + expect(resizeEvents, 'Deck onResize only fires when CSS size changes').toEqual([]); + expect(deck.needsRedraw(), 'drawing buffer resize invalidates redraw').toBeTruthy(); } finally { canvasContext.setDrawingBufferSize = originalSetDrawingBufferSize; deck.finalize(); @@ -263,15 +273,22 @@ test('Deck#useDevicePixels forwards to canvas context', async () => { } }); -test('Deck#render frame syncs provided device canvas context size', async () => { - const resizeEvents: Array<{width: number; height: number}> = []; +test('Deck#provided device resize callback drives Deck dimensions', async () => { + const originalOnResize = device.props.onResize; + let lowerLevelOnResizeCalls = 0; + device.props.onResize = () => lowerLevelOnResizeCalls++; + + const resizeEvents: Array<{ + dimensions: {width: number; height: number}; + canvasContext?: CanvasContext; + }> = []; const deck = new Deck({ device, width: 1, height: 1, viewState: {longitude: 0, latitude: 0, zoom: 0}, layers: [], - onResize: dimensions => resizeEvents.push(dimensions) + onResize: (dimensions, canvasContext) => resizeEvents.push({dimensions, canvasContext}) }); await waitForRender(deck); @@ -280,22 +297,29 @@ test('Deck#render frame syncs provided device canvas context size', async () => const canvasContext = createMockCanvasContext({getCSSSize: () => nextSize}); try { - // @ts-expect-error testing private external canvas context field - deck._externalCanvasContext = canvasContext; + // @ts-expect-error testing private canvas context setter + deck._setCanvasContext(canvasContext); resizeEvents.length = 0; + lowerLevelOnResizeCalls = 0; - // Provided Device instances do not route luma's onResize callback through Deck. - // The render loop still refreshes dimensions from CanvasContext so view state stays current. - // @ts-expect-error testing private render loop - deck._onRenderFrame(); + deck.device!.props.onResize?.(canvasContext, {oldPixelSize: [1, 1]}); - expect(deck.width, 'Deck width is refreshed during render frame').toBe(nextSize[0]); - expect(deck.height, 'Deck height is refreshed during render frame').toBe(nextSize[1]); - expect(resizeEvents, 'Deck onResize fires from render-frame canvas context sync').toEqual([ - {width: nextSize[0], height: nextSize[1]} - ]); + expect(deck.width, 'Deck width is refreshed from provided device resize').toBe(nextSize[0]); + expect(deck.height, 'Deck height is refreshed from provided device resize').toBe(nextSize[1]); + expect(resizeEvents[0]?.dimensions, 'Deck onResize fires from provided device resize').toEqual({ + width: nextSize[0], + height: nextSize[1] + }); + expect(resizeEvents[0]?.canvasContext, 'Deck onResize receives canvas context').toBe( + canvasContext + ); + expect( + lowerLevelOnResizeCalls, + 'Deck owns the lower-level luma onResize callback while active' + ).toBe(0); } finally { deck.finalize(); + device.props.onResize = originalOnResize; } }); From fbdf10627f677e73cadbab48cbd04064d49375ef Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Fri, 22 May 2026 16:35:46 -0700 Subject: [PATCH 19/21] feat(examples): add useDevicePixels toggle to basemap-browser Adds a checkbox to test useDevicePixels override (toggles between true and 1.5) across all renderer configurations. This helps verify that custom DPR settings are correctly forwarded in attached-gl/interleaved mode paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../basemap-browser/src/config/build-config.ts | 4 +++- examples/basemap-browser/src/config/dimensions.ts | 3 ++- examples/basemap-browser/src/control-panel.tsx | 14 ++++++++++++++ .../src/renderers/pure-js/deck-only.ts | 15 ++++++++++++--- .../src/renderers/pure-js/google-maps.ts | 5 +++-- .../src/renderers/pure-js/mapbox.ts | 4 +++- .../src/renderers/pure-js/maplibre.ts | 4 +++- .../src/renderers/react/deck-only-component.tsx | 13 +++++++++++-- .../src/renderers/react/google-maps-component.tsx | 10 ++++++++-- .../src/renderers/react/mapbox-component.tsx | 2 ++ .../src/renderers/react/maplibre-component.tsx | 2 ++ examples/basemap-browser/src/types.ts | 2 ++ 12 files changed, 65 insertions(+), 13 deletions(-) diff --git a/examples/basemap-browser/src/config/build-config.ts b/examples/basemap-browser/src/config/build-config.ts index f7bfe2ceadf..2eea9cdede1 100644 --- a/examples/basemap-browser/src/config/build-config.ts +++ b/examples/basemap-browser/src/config/build-config.ts @@ -24,7 +24,8 @@ export function buildConfig( dimensions: Dimensions, onViewStateChange?: ViewStateChangeCallback ): Config { - const {basemap, framework, interleaved, globe, multiView, maskDemo, stressTest} = dimensions; + const {basemap, framework, interleaved, globe, multiView, maskDemo, stressTest, useDevicePixels} = + dimensions; // Validate dimensions (warnings only) const validation = validateDimensions(dimensions); @@ -58,6 +59,7 @@ export function buildConfig( multiView, maskDemo, stressTest, + useDevicePixels, // Computed configuration mapStyle, diff --git a/examples/basemap-browser/src/config/dimensions.ts b/examples/basemap-browser/src/config/dimensions.ts index 5cd3ac3efb5..6099c2c6aec 100644 --- a/examples/basemap-browser/src/config/dimensions.ts +++ b/examples/basemap-browser/src/config/dimensions.ts @@ -14,7 +14,8 @@ export const DEFAULT_DIMENSIONS: Dimensions = { globe: false, multiView: false, maskDemo: false, - stressTest: 'none' + stressTest: 'none', + useDevicePixels: true }; /** diff --git a/examples/basemap-browser/src/control-panel.tsx b/examples/basemap-browser/src/control-panel.tsx index 9881e3a1254..d47f4b45fd7 100644 --- a/examples/basemap-browser/src/control-panel.tsx +++ b/examples/basemap-browser/src/control-panel.tsx @@ -262,6 +262,20 @@ export default function ControlPanel({onConfigChange}: ControlPanelProps) { + {/* useDevicePixels override */} +
+ +
+ {/* Stress Test Selection */}
Stress Test:
diff --git a/examples/basemap-browser/src/renderers/pure-js/deck-only.ts b/examples/basemap-browser/src/renderers/pure-js/deck-only.ts index 04e11304a2c..16f3e80feb0 100644 --- a/examples/basemap-browser/src/renderers/pure-js/deck-only.ts +++ b/examples/basemap-browser/src/renderers/pure-js/deck-only.ts @@ -7,8 +7,16 @@ import type {Config} from '../../types'; import {getBaseMapViewState} from '../../config'; export function mount(container: HTMLElement, config: Config): () => void { - const {initialViewState, layers, multiView, views, layerFilter, globe, onViewStateChange} = - config; + const { + initialViewState, + layers, + multiView, + views, + layerFilter, + globe, + useDevicePixels, + onViewStateChange + } = config; // Create a wrapper div for Deck to render into const wrapper = document.createElement('div'); @@ -24,7 +32,8 @@ export function mount(container: HTMLElement, config: Config): () => void { height: '100%', initialViewState: viewState, controller: true, - layers + layers, + useDevicePixels }; // Use GlobeView for globe projection diff --git a/examples/basemap-browser/src/renderers/pure-js/google-maps.ts b/examples/basemap-browser/src/renderers/pure-js/google-maps.ts index 32b44008083..54b21a60569 100644 --- a/examples/basemap-browser/src/renderers/pure-js/google-maps.ts +++ b/examples/basemap-browser/src/renderers/pure-js/google-maps.ts @@ -47,7 +47,7 @@ function loadGoogleMapsAPI(apiKey: string): Promise { } export function mount(container: HTMLElement, config: Config): () => void { - const {initialViewState, layers, interleaved, onViewStateChange} = config; + const {initialViewState, layers, interleaved, useDevicePixels, onViewStateChange} = config; const viewState = getBaseMapViewState(initialViewState); // eslint-disable-next-line no-process-env @@ -84,7 +84,8 @@ export function mount(container: HTMLElement, config: Config): () => void { overlay = new GoogleMapsOverlay({ interleaved, - layers + layers, + useDevicePixels }); overlay.setMap(map); diff --git a/examples/basemap-browser/src/renderers/pure-js/mapbox.ts b/examples/basemap-browser/src/renderers/pure-js/mapbox.ts index 62eff5e8e64..7f0f85c4561 100644 --- a/examples/basemap-browser/src/renderers/pure-js/mapbox.ts +++ b/examples/basemap-browser/src/renderers/pure-js/mapbox.ts @@ -17,6 +17,7 @@ export function mount(container: HTMLElement, config: Config): () => void { multiView, views, layerFilter, + useDevicePixels, onViewStateChange } = config; @@ -49,7 +50,8 @@ export function mount(container: HTMLElement, config: Config): () => void { const overlayConfig: any = { interleaved, - layers + layers, + useDevicePixels }; if (multiView && views) { diff --git a/examples/basemap-browser/src/renderers/pure-js/maplibre.ts b/examples/basemap-browser/src/renderers/pure-js/maplibre.ts index c3229aab89e..a2c111d2cbf 100644 --- a/examples/basemap-browser/src/renderers/pure-js/maplibre.ts +++ b/examples/basemap-browser/src/renderers/pure-js/maplibre.ts @@ -17,6 +17,7 @@ export function mount(container: HTMLElement, config: Config): () => void { multiView, views, layerFilter, + useDevicePixels, onViewStateChange } = config; @@ -34,7 +35,8 @@ export function mount(container: HTMLElement, config: Config): () => void { const overlayConfig: any = { interleaved, - layers + layers, + useDevicePixels }; if (multiView && views) { diff --git a/examples/basemap-browser/src/renderers/react/deck-only-component.tsx b/examples/basemap-browser/src/renderers/react/deck-only-component.tsx index 8d6ced90bd3..439f394148e 100644 --- a/examples/basemap-browser/src/renderers/react/deck-only-component.tsx +++ b/examples/basemap-browser/src/renderers/react/deck-only-component.tsx @@ -13,8 +13,16 @@ type DeckOnlyComponentProps = { }; export default function DeckOnlyComponent({config}: DeckOnlyComponentProps) { - const {initialViewState, layers, multiView, views, layerFilter, globe, onViewStateChange} = - config; + const { + initialViewState, + layers, + multiView, + views, + layerFilter, + globe, + useDevicePixels, + onViewStateChange + } = config; const handleViewStateChange = useCallback( ({viewState: vs}: {viewState: any}) => { @@ -63,6 +71,7 @@ export default function DeckOnlyComponent({config}: DeckOnlyComponentProps) { controller={true} layers={layers} views={effectiveViews} + useDevicePixels={useDevicePixels} layerFilter={multiView ? layerFilter : undefined} onViewStateChange={handleViewStateChange} /> diff --git a/examples/basemap-browser/src/renderers/react/google-maps-component.tsx b/examples/basemap-browser/src/renderers/react/google-maps-component.tsx index 11338feb0fa..b0d7f4b385e 100644 --- a/examples/basemap-browser/src/renderers/react/google-maps-component.tsx +++ b/examples/basemap-browser/src/renderers/react/google-maps-component.tsx @@ -12,14 +12,19 @@ import {getBaseMapViewState} from '../../config'; function GoogleMapsDeckOverlay({ layers, interleaved, + useDevicePixels, onViewStateChange }: { layers: any[]; interleaved: boolean; + useDevicePixels?: boolean | number; onViewStateChange?: (vs: InitialViewState) => void; }) { const map = useMap(); - const overlay = useMemo(() => new GoogleMapsOverlay({interleaved}), [interleaved]); + const overlay = useMemo( + () => new GoogleMapsOverlay({interleaved, useDevicePixels}), + [interleaved, useDevicePixels] + ); useEffect(() => { if (map) { @@ -62,7 +67,7 @@ export default function GoogleMapsComponent({config}: GoogleMapsComponentProps) // eslint-disable-next-line no-process-env const mapId = process.env.GoogleMapsMapId || 'DEMO_MAP_ID'; - const {initialViewState, layers, interleaved, onViewStateChange} = config; + const {initialViewState, layers, interleaved, useDevicePixels, onViewStateChange} = config; const viewState = getBaseMapViewState(initialViewState); return ( @@ -79,6 +84,7 @@ export default function GoogleMapsComponent({config}: GoogleMapsComponentProps) diff --git a/examples/basemap-browser/src/renderers/react/mapbox-component.tsx b/examples/basemap-browser/src/renderers/react/mapbox-component.tsx index 322ac4ff8c5..06b3c8f356f 100644 --- a/examples/basemap-browser/src/renderers/react/mapbox-component.tsx +++ b/examples/basemap-browser/src/renderers/react/mapbox-component.tsx @@ -33,6 +33,7 @@ export default function MapboxComponent({config}: MapboxComponentProps) { multiView, views, layerFilter, + useDevicePixels, onViewStateChange } = config; @@ -59,6 +60,7 @@ export default function MapboxComponent({config}: MapboxComponentProps) { Date: Wed, 27 May 2026 14:45:32 -0700 Subject: [PATCH 20/21] feat(examples): use basemap pixelRatio for interleaved mode + add CDN test pages Pass pixelRatio to MapLibre/Mapbox Map constructor when useDevicePixels is set to a custom value, since the basemap owns the canvas in interleaved mode. Also adds standalone HTML test pages for validating useDevicePixels across deck.gl 9.1, 9.2, 9.3, and local builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../basemap-browser/src/control-panel.tsx | 4 +- .../src/renderers/pure-js/mapbox.ts | 10 +- .../src/renderers/pure-js/maplibre.ts | 10 +- .../src/renderers/react/mapbox-component.tsx | 9 +- .../renderers/react/maplibre-component.tsx | 9 +- .../use-device-pixels-cdn-test-9.2.html | 158 +++++++++++++++ .../use-device-pixels-cdn-test-9.3.html | 158 +++++++++++++++ .../use-device-pixels-cdn-test.html | 158 +++++++++++++++ .../use-device-pixels-local-test.html | 180 +++++++++++++++++ ...device-pixels-maplibre-ratio-test-9.3.html | 182 ++++++++++++++++++ ...use-device-pixels-maplibre-ratio-test.html | 182 ++++++++++++++++++ 11 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 examples/basemap-browser/use-device-pixels-cdn-test-9.2.html create mode 100644 examples/basemap-browser/use-device-pixels-cdn-test-9.3.html create mode 100644 examples/basemap-browser/use-device-pixels-cdn-test.html create mode 100644 examples/basemap-browser/use-device-pixels-local-test.html create mode 100644 examples/basemap-browser/use-device-pixels-maplibre-ratio-test-9.3.html create mode 100644 examples/basemap-browser/use-device-pixels-maplibre-ratio-test.html diff --git a/examples/basemap-browser/src/control-panel.tsx b/examples/basemap-browser/src/control-panel.tsx index d47f4b45fd7..2f41882cd46 100644 --- a/examples/basemap-browser/src/control-panel.tsx +++ b/examples/basemap-browser/src/control-panel.tsx @@ -262,7 +262,7 @@ export default function ControlPanel({onConfigChange}: ControlPanelProps) {
- {/* useDevicePixels override */} + {/* Pixel Ratio Override */}
diff --git a/examples/basemap-browser/src/renderers/pure-js/mapbox.ts b/examples/basemap-browser/src/renderers/pure-js/mapbox.ts index 7f0f85c4561..6912981df7d 100644 --- a/examples/basemap-browser/src/renderers/pure-js/mapbox.ts +++ b/examples/basemap-browser/src/renderers/pure-js/mapbox.ts @@ -39,14 +39,20 @@ export function mount(container: HTMLElement, config: Config): () => void { // For multi-view, extract the mapbox view state for the base map const mapInitialViewState = getBaseMapViewState(initialViewState); - const map = new mapboxgl.Map({ + const mapOpts: any = { container, style: mapStyle, center: [mapInitialViewState.longitude, mapInitialViewState.latitude], zoom: mapInitialViewState.zoom, bearing: mapInitialViewState.bearing || 0, pitch: mapInitialViewState.pitch || 0 - }); + }; + if (typeof useDevicePixels === 'number') { + mapOpts.pixelRatio = useDevicePixels; + } else if (useDevicePixels === false) { + mapOpts.pixelRatio = 1; + } + const map = new mapboxgl.Map(mapOpts); const overlayConfig: any = { interleaved, diff --git a/examples/basemap-browser/src/renderers/pure-js/maplibre.ts b/examples/basemap-browser/src/renderers/pure-js/maplibre.ts index a2c111d2cbf..7e26371cf53 100644 --- a/examples/basemap-browser/src/renderers/pure-js/maplibre.ts +++ b/examples/basemap-browser/src/renderers/pure-js/maplibre.ts @@ -24,14 +24,20 @@ export function mount(container: HTMLElement, config: Config): () => void { // For multi-view, extract the mapbox view state for the base map const mapInitialViewState = getBaseMapViewState(initialViewState); - const map = new maplibregl.Map({ + const mapOpts: maplibregl.MapOptions = { container, style: mapStyle, center: [mapInitialViewState.longitude, mapInitialViewState.latitude], zoom: mapInitialViewState.zoom, bearing: mapInitialViewState.bearing || 0, pitch: mapInitialViewState.pitch || 0 - }); + }; + if (typeof useDevicePixels === 'number') { + mapOpts.pixelRatio = useDevicePixels; + } else if (useDevicePixels === false) { + mapOpts.pixelRatio = 1; + } + const map = new maplibregl.Map(mapOpts); const overlayConfig: any = { interleaved, diff --git a/examples/basemap-browser/src/renderers/react/mapbox-component.tsx b/examples/basemap-browser/src/renderers/react/mapbox-component.tsx index 06b3c8f356f..560a08dfe9e 100644 --- a/examples/basemap-browser/src/renderers/react/mapbox-component.tsx +++ b/examples/basemap-browser/src/renderers/react/mapbox-component.tsx @@ -43,10 +43,17 @@ export default function MapboxComponent({config}: MapboxComponentProps) { return (
{ onViewStateChange?.({ latitude: e.viewState.latitude, diff --git a/examples/basemap-browser/src/renderers/react/maplibre-component.tsx b/examples/basemap-browser/src/renderers/react/maplibre-component.tsx index eae843ef5f7..5f3935fb8c7 100644 --- a/examples/basemap-browser/src/renderers/react/maplibre-component.tsx +++ b/examples/basemap-browser/src/renderers/react/maplibre-component.tsx @@ -59,9 +59,16 @@ export default function MapLibreComponent({config}: MapLibreComponentProps) { return (
{ if (globe && isMountedRef.current) { // Set projection before rendering overlay (critical for globe + interleaved mode) diff --git a/examples/basemap-browser/use-device-pixels-cdn-test-9.2.html b/examples/basemap-browser/use-device-pixels-cdn-test-9.2.html new file mode 100644 index 00000000000..752a038802e --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-cdn-test-9.2.html @@ -0,0 +1,158 @@ + + + + + useDevicePixels test (CDN - deck.gl 9.2) + + + + + + +

useDevicePixels test CDN deck.gl 9.2

+

Side-by-side: standalone Deck vs MapboxOverlay interleaved. Uses published deck.gl 9.1 UMD bundle.

+ +
+ + + + +
+ +
+
+

Standalone Deck (internal device)

+
+
Loading...
+
+
+

MapboxOverlay interleaved (attached gl)

+
+
Loading...
+
+
+ + + + diff --git a/examples/basemap-browser/use-device-pixels-cdn-test-9.3.html b/examples/basemap-browser/use-device-pixels-cdn-test-9.3.html new file mode 100644 index 00000000000..400e9d4409d --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-cdn-test-9.3.html @@ -0,0 +1,158 @@ + + + + + useDevicePixels test (CDN - deck.gl 9.3) + + + + + + +

useDevicePixels test CDN deck.gl 9.3

+

Side-by-side: standalone Deck vs MapboxOverlay interleaved. Uses published deck.gl 9.1 UMD bundle.

+ +
+ + + + +
+ +
+
+

Standalone Deck (internal device)

+
+
Loading...
+
+
+

MapboxOverlay interleaved (attached gl)

+
+
Loading...
+
+
+ + + + diff --git a/examples/basemap-browser/use-device-pixels-cdn-test.html b/examples/basemap-browser/use-device-pixels-cdn-test.html new file mode 100644 index 00000000000..50cc9067401 --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-cdn-test.html @@ -0,0 +1,158 @@ + + + + + useDevicePixels test (CDN - deck.gl 9.1) + + + + + + +

useDevicePixels test CDN deck.gl 9.1

+

Side-by-side: standalone Deck vs MapboxOverlay interleaved. Uses published deck.gl 9.1 UMD bundle.

+ +
+ + + + +
+ +
+
+

Standalone Deck (internal device)

+
+
Loading...
+
+
+

MapboxOverlay interleaved (attached gl)

+
+
Loading...
+
+
+ + + + diff --git a/examples/basemap-browser/use-device-pixels-local-test.html b/examples/basemap-browser/use-device-pixels-local-test.html new file mode 100644 index 00000000000..13a969baef9 --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-local-test.html @@ -0,0 +1,180 @@ + + + + + useDevicePixels test (LOCAL build) + + + +

useDevicePixels test LOCAL BUILD

+

Side-by-side comparison: standalone Deck (useDevicePixels) vs MapboxOverlay interleaved (MapLibre pixelRatio + useDevicePixels).
+ Interleaved mode recreates the map with the appropriate pixelRatio.

+ +
+ + + + +
+ +
+
+

Standalone Deck (internal device)

+
+
Loading...
+
+
+

MapboxOverlay interleaved (attached gl)

+
+
Loading...
+
+
+ + + + diff --git a/examples/basemap-browser/use-device-pixels-maplibre-ratio-test-9.3.html b/examples/basemap-browser/use-device-pixels-maplibre-ratio-test-9.3.html new file mode 100644 index 00000000000..e4a754504be --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-maplibre-ratio-test-9.3.html @@ -0,0 +1,182 @@ + + + + + useDevicePixels test - MapLibre pixelRatio vs Deck useDevicePixels + + + + + + +

MapLibre pixelRatio vs Deck useDevicePixels deck.gl 9.3

+

Left: MapLibre's pixelRatio controls the canvas buffer. Right: Deck's useDevicePixels on standalone.
+ This tests whether the interleaved buffer is controlled by MapLibre's pixelRatio (not Deck's useDevicePixels).

+ +
+ + + + +
+ +
+
+

MapboxOverlay interleaved

+
+
Loading...
+
+
+

Standalone Deck

+
+
Loading...
+
+
+ + + + diff --git a/examples/basemap-browser/use-device-pixels-maplibre-ratio-test.html b/examples/basemap-browser/use-device-pixels-maplibre-ratio-test.html new file mode 100644 index 00000000000..66ca5520c35 --- /dev/null +++ b/examples/basemap-browser/use-device-pixels-maplibre-ratio-test.html @@ -0,0 +1,182 @@ + + + + + useDevicePixels test - MapLibre pixelRatio vs Deck useDevicePixels + + + + + + +

MapLibre pixelRatio vs Deck useDevicePixels deck.gl 9.1

+

Left: MapLibre's pixelRatio controls the canvas buffer. Right: Deck's useDevicePixels on standalone.
+ This tests whether the interleaved buffer is controlled by MapLibre's pixelRatio (not Deck's useDevicePixels).

+ +
+ + + + +
+ +
+
+

MapboxOverlay interleaved

+
+
Loading...
+
+
+

Standalone Deck

+
+
Loading...
+
+
+ + + + From cbecd5408c73f1411e30671d31da0be4f6375ca6 Mon Sep 17 00:00:00 2001 From: Chris Gervang Date: Wed, 27 May 2026 15:14:02 -0700 Subject: [PATCH 21/21] fix(examples): persist useDevicePixels in URL params Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/basemap-browser/src/control-panel.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/basemap-browser/src/control-panel.tsx b/examples/basemap-browser/src/control-panel.tsx index 2f41882cd46..c60372cbebc 100644 --- a/examples/basemap-browser/src/control-panel.tsx +++ b/examples/basemap-browser/src/control-panel.tsx @@ -68,6 +68,20 @@ function getDimensionsFromUrl(): Partial { result.stressTest = stressTest; } + if (params.has('useDevicePixels')) { + const udp = params.get('useDevicePixels'); + if (udp === 'true') { + result.useDevicePixels = true; + } else if (udp === 'false') { + result.useDevicePixels = false; + } else { + const num = Number(udp); + if (Number.isFinite(num) && num > 0) { + result.useDevicePixels = num; + } + } + } + return result; } @@ -81,6 +95,7 @@ function setUrlFromDimensions(dimensions: Dimensions) { params.set('multiView', String(dimensions.multiView)); params.set('maskDemo', String(dimensions.maskDemo)); params.set('stressTest', dimensions.stressTest); + params.set('useDevicePixels', String(dimensions.useDevicePixels)); const newUrl = `${window.location.pathname}?${params.toString()}`; window.history.replaceState({}, '', newUrl); }