From 46b4d69a94848cd719a8ece420d5f0946062ad6f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 14:55:47 -0400 Subject: [PATCH 01/12] docs: spec for destroying tile textures on eviction (#591) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-05-destroy-tile-textures-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 dev-docs/specs/2026-06-05-destroy-tile-textures-design.md diff --git a/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md b/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md new file mode 100644 index 00000000..9e3caf47 --- /dev/null +++ b/dev-docs/specs/2026-06-05-destroy-tile-textures-design.md @@ -0,0 +1,131 @@ +# Destroy tile textures on eviction — Design + +- **Date:** 2026-06-05 +- **Issues:** [#591](https://github.com/developmentseed/deck.gl-raster/issues/591) +- **Status:** Proposed + +## Problem + +When a tile is evicted from deck.gl's `TileLayer` cache, the GPU texture(s) we +uploaded for that tile are never freed. A luma.gl `Texture` is a thin JS wrapper +around a WebGL texture handle that holds GPU memory; JS garbage collection will +eventually reclaim the *wrapper object* but does **not** deterministically free +the underlying GL resource — luma.gl requires an explicit `.destroy()`. deck.gl's +own [`onTileUnload` docs](https://deck.gl/docs/api-reference/geo-layers/tile-layer#ontileunload) +state that the caller owns anything returned from `getTileData`, and passing a +texture as a uniform/binding to a `RasterLayer` sublayer does **not** transfer +ownership — luma only auto-destroys textures it created itself. + +The result is a GPU-memory leak that accumulates as the user pans and zooms. + +`onTileUnload` is the correct lifecycle hook: it fires on **cache eviction**, not +when a tile merely scrolls off-screen. A tile that goes off-screen but stays in +cache is re-shown without re-fetching, so destroying on off-screen would be wrong. +Eviction is the precise moment a tile's textures are provably no longer needed. + +The leak exists in two places: + +1. **The library's own default pipelines.** `COGLayer`'s default render pipeline + creates a `texture` (+ optional `mask`) per tile + (`packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts`), and `MultiCOGLayer` + creates one texture per band (`packages/deck.gl-geotiff/src/multi-cog-layer.ts`). + Neither is ever destroyed. +2. **Examples with custom `getTileData`.** Several examples upload their own + textures via `device.createTexture()` and never free them. + +## Ownership model + +The fix follows a single principle: **whoever creates a texture frees it.** + +- The library creates textures only inside its own default pipelines. It should + destroy them — but *only* when its default pipeline actually ran. +- A user who supplies a custom `getTileData` (or `getn`) owns the tile data and + its textures, and is responsible for freeing them in their own `onTileUnload`. + The library must not reach into a user-shaped `tile.data` and assume a texture + lives at `.texture`. + +This keeps the library's behavior predictable: passing `getTileData` means "I own +this tile's resources." + +## Layer-by-layer + +| Layer | Creates textures? | Library cleanup? | +| --- | --- | --- | +| `RasterTileLayer` (base) | No default pipeline | None — returns user `onTileUnload` unchanged | +| `COGLayer` | Only when `!props.getTileData` | Destroy `tile.data.texture` + `tile.data.mask`, **only** when default pipeline ran | +| `MultiCOGLayer` | Always (no user-`getTileData` path) | Always destroy every `tile.data.bands[*].texture` | +| `ZarrLayer` | Never (requires user `getTileData`) | None | + +## Wiring + +Add a protected `_onTileUnloadCallback()` hook on `RasterTileLayer`, mirroring the +existing `_getTileDataCallback()` / `_renderTileCallback()` / `_tilesetDescriptor()` +override pattern. The base `renderLayers` passes its result to the inner +`TileLayer` in place of `this.props.onTileUnload`. + +- **Base `RasterTileLayer`**: returns `this.props.onTileUnload` unchanged. +- **`COGLayer`** overrides it: when `!this.props.getTileData`, compose a destroyer + of `tile.data.texture` + `tile.data.mask` with the user's `onTileUnload`; + otherwise return the user's `onTileUnload` untouched. +- **`MultiCOGLayer`** overrides it: always compose a destroyer that walks + `tile.data.bands.values()` and destroys each `BandTileData.texture`. +- **`ZarrLayer`**: no override; inherits the base behavior. + +Composition rules: + +- Library cleanup runs **after** the user's `onTileUnload`, so user code still + observes live textures if it inspects them. +- Destroyers guard each field with `instanceof Texture` before calling + `.destroy()`, so a same-named non-Texture field is never touched. +- luma.gl's `.destroy()` is idempotent, so composition is safe even if a user + callback also frees the same texture. + +The "library created the texture" condition for `COGLayer` is precisely +`!this.props.getTileData`: `_getTileDataCallback()` returns +`props.getTileData ?? state.defaultGetTileData`, so the default pipeline's textures +exist exactly when `props.getTileData` is absent. (A user who passes `getTileData` +but not `renderTile` still supplies the textures, so no cleanup — correct.) + +## Helper + +Library cleanup uses an **internal-only** helper (e.g. `destroyTileTextures`); it +is not added to any barrel export. Examples destroy their own textures with an +inline one-liner. This keeps the public API surface minimal — there is no external +helper to maintain or document. + +## Examples + +Covered by library cleanup with **zero edits**: + +- `cog-basic`, `titiler-cog`, `cog-globe`, `globe-view` — stock `COGLayer`. +- `sentinel-2` — `MultiCOGLayer`. + +Need their own `onTileUnload` (custom `getTileData`/`getn`, user-owned textures): + +- COG: `usgs-topo-cutline`, `vermont-cog-comparison`, `naip-mosaic` (inner + `COGLayer`), `land-cover` — destroy `tile.data.texture`. Shared colormap/filter + textures are module-level and intentionally left alone. +- Zarr: `dynamical-zarr-ecmwf`, `aef-mosaic`, `nldas-icechunk` — destroy + `tile.data.texture`. + +To audit during implementation: + +- `zarr-sentinel2-tci` returns `{ image }` (a CPU `ImageData`, GC-safe) rather than + a `Texture`. Where its render texture is created and whether *that* leaks is a + separate question; confirm before deciding whether it needs cleanup. + +## Testing + +- Unit-test each destroyer: destroys Texture-valued `texture`/`mask`; ignores + absent or non-Texture fields; walks the bands map and destroys every band + texture. +- Test `COGLayer` installs cleanup only when `getTileData` is absent, and composes + the destroyer with a user-supplied `onTileUnload` (both run). +- Test `MultiCOGLayer` always installs cleanup. + +## Out of scope + +- No opt-out prop (e.g. `_destroyTileTextures: false`). Nothing currently relies on + texture retention, and this is a `0.8` beta. An escape hatch can be added later + if a concrete need for retaining a `tile.data.texture` appears. +- No public texture-cleanup helper export (see Helper). From aed30523af4ce633ed5aad18d3947f76a3dbe5ed Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:08:40 -0400 Subject: [PATCH 02/12] feat(geotiff): add internal destroyIfTexture helper (#591) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deck.gl-geotiff/src/texture-cleanup.ts | 15 ++++++++ .../tests/texture-cleanup.test.ts | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/deck.gl-geotiff/src/texture-cleanup.ts create mode 100644 packages/deck.gl-geotiff/tests/texture-cleanup.test.ts diff --git a/packages/deck.gl-geotiff/src/texture-cleanup.ts b/packages/deck.gl-geotiff/src/texture-cleanup.ts new file mode 100644 index 00000000..a3111b55 --- /dev/null +++ b/packages/deck.gl-geotiff/src/texture-cleanup.ts @@ -0,0 +1,15 @@ +import { Texture } from "@luma.gl/core"; + +/** + * Destroy `value` if it is a luma.gl {@link Texture}, freeing its GPU memory. + * + * A no-op for `undefined`, `null`, or any non-`Texture` value, so it is safe to + * call on optional fields (e.g. an absent mask) without guarding first. luma's + * `Texture.destroy()` is idempotent, so calling this twice on the same texture + * is harmless. + */ +export function destroyIfTexture(value: unknown): void { + if (value instanceof Texture) { + value.destroy(); + } +} diff --git a/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts b/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts new file mode 100644 index 00000000..323099b0 --- /dev/null +++ b/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts @@ -0,0 +1,38 @@ +import { Texture } from "@luma.gl/core"; +import { describe, expect, it, vi } from "vitest"; +import { destroyIfTexture } from "../src/texture-cleanup.js"; + +/** + * An object that passes `instanceof Texture` (shares Texture's prototype) with a + * spy `destroy`. We can't `new Texture()` (it is abstract / needs a device), so + * we synthesize the prototype link directly. + */ +function fakeTexture(): Texture & { destroy: ReturnType } { + const tex = Object.create(Texture.prototype) as Texture & { + destroy: ReturnType; + }; + tex.destroy = vi.fn(); + return tex; +} + +describe("destroyIfTexture", () => { + it("destroys a value that is a Texture", () => { + const tex = fakeTexture(); + destroyIfTexture(tex); + expect(tex.destroy).toHaveBeenCalledOnce(); + }); + + it("ignores undefined", () => { + expect(() => destroyIfTexture(undefined)).not.toThrow(); + }); + + it("ignores null", () => { + expect(() => destroyIfTexture(null)).not.toThrow(); + }); + + it("does not destroy a non-Texture object that happens to have destroy()", () => { + const notTexture = { destroy: vi.fn() }; + destroyIfTexture(notTexture); + expect(notTexture.destroy).not.toHaveBeenCalled(); + }); +}); From d51c7716832616e2e33200e65f643a4df42a760d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:09:56 -0400 Subject: [PATCH 03/12] feat(raster): add _onTileUnloadCallback hook on RasterTileLayer (#591) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/raster-tile-layer/raster-tile-layer.ts | 14 ++++++++++++-- .../raster-tile-layer/raster-tile-layer.test.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 8981f843..3b076056 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -225,6 +225,17 @@ export class RasterTileLayer< return (this.props as unknown as RasterTileLayerProps).renderTile; } + /** + * The currently effective tile-unload callback. + * + * Subclasses override this to destroy GPU textures their default pipeline + * created before delegating to the user's `onTileUnload`. The base layer owns + * no textures, so it returns the user's callback unchanged. + */ + protected _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + return (this.props as unknown as RasterTileLayerProps).onTileUnload; + } + /** * Hook for rendering per-tile debug overlay sub-layers. * @@ -314,7 +325,6 @@ export class RasterTileLayer< updateTriggers, onTileError, onTileLoad, - onTileUnload, onViewportLoad, } = this.props; @@ -348,7 +358,7 @@ export class RasterTileLayer< refinementStrategy, onTileError, onTileLoad, - onTileUnload, + onTileUnload: this._onTileUnloadCallback(), onViewportLoad, }); } diff --git a/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts b/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts index 0b03cb08..925b7516 100644 --- a/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts +++ b/packages/deck.gl-raster/tests/raster-tile-layer/raster-tile-layer.test.ts @@ -31,4 +31,19 @@ describe("RasterTileLayer", () => { const layer = new ProbeLayer({ id: "probe" }); expect(layer.callDebug({ id: "x" }, null)).toEqual([]); }); + + it("base _onTileUnloadCallback returns the user onTileUnload unchanged", () => { + const onTileUnload = () => {}; + class ProbeLayer extends RasterTileLayer { + callUnload() { + return ( + this as unknown as { + _onTileUnloadCallback: () => unknown; + } + )._onTileUnloadCallback(); + } + } + const layer = new ProbeLayer({ id: "probe", onTileUnload }); + expect(layer.callUnload()).toBe(onTileUnload); + }); }); From 0e64f9aa861fbcab7dd8924a8b5792aab3de144a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:13:01 -0400 Subject: [PATCH 04/12] feat(geotiff): destroy default-pipeline tile textures on unload in COGLayer (#591) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/deck.gl-geotiff/src/cog-layer.ts | 16 ++++ .../deck.gl-geotiff/tests/cog-layer.test.ts | 79 +++++++++++++++++++ .../tests/texture-cleanup.test.ts | 22 +++--- 3 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 packages/deck.gl-geotiff/tests/cog-layer.test.ts diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index bf68f090..ee35970b 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -28,6 +28,7 @@ import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import type { TextureDataT } from "./geotiff/render-pipeline.js"; import { inferRenderPipeline } from "./geotiff/render-pipeline.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; +import { destroyIfTexture } from "./texture-cleanup.js"; export type { MinimalTileData } from "@developmentseed/deck.gl-raster"; @@ -356,4 +357,19 @@ export class COGLayer< return userFn as NonNullable["renderTile"]>; } + + protected override _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + const onTileUnload = this.props.onTileUnload; + // A user-supplied `getTileData` owns its own textures and is responsible for + // freeing them. Only destroy textures the default pipeline created. + if (this.props.getTileData) { + return onTileUnload; + } + return (tile) => { + onTileUnload?.(tile); + const data = tile.data as TextureDataT | null; + destroyIfTexture(data?.texture); + destroyIfTexture(data?.mask); + }; + } } diff --git a/packages/deck.gl-geotiff/tests/cog-layer.test.ts b/packages/deck.gl-geotiff/tests/cog-layer.test.ts new file mode 100644 index 00000000..53fe4cdd --- /dev/null +++ b/packages/deck.gl-geotiff/tests/cog-layer.test.ts @@ -0,0 +1,79 @@ +import { Texture } from "@luma.gl/core"; +import { describe, expect, it, vi } from "vitest"; +import { COGLayer } from "../src/cog-layer.js"; + +function fakeTexture(): { + texture: Texture; + destroy: ReturnType; +} { + const destroy = vi.fn(); + const texture = Object.create(Texture.prototype) as Texture; + Object.defineProperty(texture, "destroy", { value: destroy }); + return { texture, destroy }; +} + +/** Expose the protected `_onTileUnloadCallback` for testing. */ +function unloadCallback(layer: COGLayer) { + return ( + layer as unknown as { + _onTileUnloadCallback: () => + | ((tile: { data: unknown }) => void) + | undefined; + } + )._onTileUnloadCallback(); +} + +describe("COGLayer._onTileUnloadCallback", () => { + it("destroys texture + mask and calls the user callback when no getTileData", () => { + const userCalls: unknown[] = []; + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + onTileUnload: (tile: unknown) => userCalls.push(tile), + } as never); + + const cb = unloadCallback(layer); + expect(cb).toBeTypeOf("function"); + + const texture = fakeTexture(); + const mask = fakeTexture(); + const tile = { data: { texture: texture.texture, mask: mask.texture } }; + cb?.(tile); + + expect(texture.destroy).toHaveBeenCalledOnce(); + expect(mask.destroy).toHaveBeenCalledOnce(); + expect(userCalls).toEqual([tile]); + }); + + it("tolerates a tile with no data and a missing mask", () => { + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + } as never); + const cb = unloadCallback(layer); + + expect(() => cb?.({ data: null })).not.toThrow(); + + const texture = fakeTexture(); + cb?.({ data: { texture: texture.texture } }); + expect(texture.destroy).toHaveBeenCalledOnce(); + }); + + it("returns the user onTileUnload unchanged when getTileData is supplied", () => { + const onTileUnload = () => {}; + const layer = new COGLayer({ + id: "cog", + url: "https://example.com/x.tif", + getTileData: async () => ({ + texture: fakeTexture().texture, + width: 1, + height: 1, + byteLength: 4, + }), + renderTile: () => ({ renderPipeline: [] }), + onTileUnload, + } as never); + + expect(unloadCallback(layer)).toBe(onTileUnload); + }); +}); diff --git a/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts b/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts index 323099b0..12309d47 100644 --- a/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts +++ b/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts @@ -3,23 +3,25 @@ import { describe, expect, it, vi } from "vitest"; import { destroyIfTexture } from "../src/texture-cleanup.js"; /** - * An object that passes `instanceof Texture` (shares Texture's prototype) with a + * A value that passes `instanceof Texture` (shares Texture's prototype) plus a * spy `destroy`. We can't `new Texture()` (it is abstract / needs a device), so * we synthesize the prototype link directly. */ -function fakeTexture(): Texture & { destroy: ReturnType } { - const tex = Object.create(Texture.prototype) as Texture & { - destroy: ReturnType; - }; - tex.destroy = vi.fn(); - return tex; +function fakeTexture(): { + texture: Texture; + destroy: ReturnType; +} { + const destroy = vi.fn(); + const texture = Object.create(Texture.prototype) as Texture; + Object.defineProperty(texture, "destroy", { value: destroy }); + return { texture, destroy }; } describe("destroyIfTexture", () => { it("destroys a value that is a Texture", () => { - const tex = fakeTexture(); - destroyIfTexture(tex); - expect(tex.destroy).toHaveBeenCalledOnce(); + const { texture, destroy } = fakeTexture(); + destroyIfTexture(texture); + expect(destroy).toHaveBeenCalledOnce(); }); it("ignores undefined", () => { From 531b6f6849f8fd0f516086cdf270c9d150f9e441 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:14:30 -0400 Subject: [PATCH 05/12] feat(geotiff): destroy band textures on unload in MultiCOGLayer (#591) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deck.gl-geotiff/src/multi-cog-layer.ts | 18 ++++++ .../tests/multi-cog-layer.test.ts | 56 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 8472054e..b60bfc68 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -16,6 +16,7 @@ import type { MultiRasterTilesetDescriptor, ProjectionFunction, RasterModule, + RasterTileLayerProps, RasterTilesetDescriptor, RasterTilesetLevel, RenderTileResult, @@ -52,6 +53,7 @@ import proj4 from "proj4"; import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; +import { destroyIfTexture } from "./texture-cleanup.js"; /** * Color palette for debug overlays. @@ -621,6 +623,22 @@ export class MultiCOGLayer extends RasterTileLayer< this._buildRenderResult(data); } + protected override _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { + const onTileUnload = this.props.onTileUnload; + // MultiCOGLayer always creates the band textures itself (there is no + // user-supplied `getTileData`), so it always frees them. + return (tile) => { + onTileUnload?.(tile); + const data = tile.data as MultiTileResult | null; + if (!data) { + return; + } + for (const band of data.bands.values()) { + destroyIfTexture(band.texture); + } + }; + } + protected override _renderDebug( tile: Tile2DHeader, data: MultiTileResult | null, diff --git a/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts new file mode 100644 index 00000000..96c5fabb --- /dev/null +++ b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts @@ -0,0 +1,56 @@ +import { Texture } from "@luma.gl/core"; +import { describe, expect, it, vi } from "vitest"; +import { MultiCOGLayer } from "../src/multi-cog-layer.js"; + +function fakeTexture(): { + texture: Texture; + destroy: ReturnType; +} { + const destroy = vi.fn(); + const texture = Object.create(Texture.prototype) as Texture; + Object.defineProperty(texture, "destroy", { value: destroy }); + return { texture, destroy }; +} + +function unloadCallback(layer: MultiCOGLayer) { + return ( + layer as unknown as { + _onTileUnloadCallback: () => + | ((tile: { data: unknown }) => void) + | undefined; + } + )._onTileUnloadCallback(); +} + +describe("MultiCOGLayer._onTileUnloadCallback", () => { + it("destroys every band texture and calls the user callback", () => { + const userCalls: unknown[] = []; + const layer = new MultiCOGLayer({ + id: "multi", + sources: {}, + onTileUnload: (tile: unknown) => userCalls.push(tile), + } as never); + + const cb = unloadCallback(layer); + expect(cb).toBeTypeOf("function"); + + const texA = fakeTexture(); + const texB = fakeTexture(); + const bands = new Map([ + ["a", { texture: texA.texture }], + ["b", { texture: texB.texture }], + ]); + const tile = { data: { bands } }; + cb?.(tile); + + expect(texA.destroy).toHaveBeenCalledOnce(); + expect(texB.destroy).toHaveBeenCalledOnce(); + expect(userCalls).toEqual([tile]); + }); + + it("tolerates a tile with no data", () => { + const layer = new MultiCOGLayer({ id: "multi", sources: {} } as never); + const cb = unloadCallback(layer); + expect(() => cb?.({ data: null })).not.toThrow(); + }); +}); From 971571f38e5fccaf60d9e76a12d09c311b46265c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:17:18 -0400 Subject: [PATCH 06/12] fix(examples): destroy user-created tile textures on unload (#591) Examples that supply a custom getTileData own their tile textures; the library only frees textures its default pipeline created. Free each tile's texture in onTileUnload (cache eviction). Shared colormap/filter textures are module-level and intentionally left alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/aef-mosaic/src/App.tsx | 2 ++ examples/dynamical-zarr-ecmwf/src/App.tsx | 2 ++ examples/land-cover/src/App.tsx | 3 +++ examples/naip-mosaic/src/App.tsx | 2 ++ examples/nldas-icechunk/src/App.tsx | 2 ++ examples/usgs-topo-cutline/src/App.tsx | 2 ++ examples/vermont-cog-comparison/src/App.tsx | 2 ++ 7 files changed, 15 insertions(+) diff --git a/examples/aef-mosaic/src/App.tsx b/examples/aef-mosaic/src/App.tsx index 264a6070..3cd3a80c 100644 --- a/examples/aef-mosaic/src/App.tsx +++ b/examples/aef-mosaic/src/App.tsx @@ -96,6 +96,8 @@ export default function App() { selection, getTileData, renderTile, + onTileUnload: (tile) => + (tile.data as AefTileData | null)?.texture.destroy(), minZoom: MIN_ZOOM, // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain diff --git a/examples/dynamical-zarr-ecmwf/src/App.tsx b/examples/dynamical-zarr-ecmwf/src/App.tsx index 56541e3d..05cf48f4 100644 --- a/examples/dynamical-zarr-ecmwf/src/App.tsx +++ b/examples/dynamical-zarr-ecmwf/src/App.tsx @@ -215,6 +215,8 @@ export default function App() { selection, getTileData, renderTile, + onTileUnload: (tile) => + (tile.data as EcmwfTileData | null)?.texture.destroy(), // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain maxRequests: 20, diff --git a/examples/land-cover/src/App.tsx b/examples/land-cover/src/App.tsx index 50cfe46d..7a2d4747 100644 --- a/examples/land-cover/src/App.tsx +++ b/examples/land-cover/src/App.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; import { InfoPanel } from "./components/InfoPanel.js"; +import type { LandCoverTileData } from "./get-tile-data.js"; import { getTileData } from "./get-tile-data.js"; import { buildColormapTexture } from "./nlcd/build-colormap-texture.js"; import { @@ -98,6 +99,8 @@ export default function App() { epsgResolver, getTileData, renderTile, + onTileUnload: (tile) => + (tile.data as LandCoverTileData | null)?.texture.destroy(), onGeoTIFFLoad: (tiff, options) => { setGeotiff(tiff); const { west, south, east, north } = options.geographicBounds; diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 6f0dc050..1206491f 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -433,6 +433,8 @@ export default function App() { colormapIndex: colormapChoice.colormapIndex, colormapReversed: colormapChoice.reversed, }), + onTileUnload: (tile) => + (tile.data as TextureDataT | null)?.texture.destroy(), signal, }); }, diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index b6994f0f..bbe03e92 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -132,6 +132,8 @@ export default function App() { rescaleMin, rescaleMax, }), + onTileUnload: (tile) => + (tile.data as NldasTileData | null)?.texture.destroy(), // Re-run renderTile on cached tiles when the colormap or rescale // range changes. updateTriggers: { diff --git a/examples/usgs-topo-cutline/src/App.tsx b/examples/usgs-topo-cutline/src/App.tsx index fe09d5fa..7787368e 100644 --- a/examples/usgs-topo-cutline/src/App.tsx +++ b/examples/usgs-topo-cutline/src/App.tsx @@ -182,6 +182,8 @@ export default function App() { geotiff: selected.url, getTileData, renderTile: (data) => renderTile(data, cutlineEnabled, selected.bbox), + onTileUnload: (tile) => + (tile.data as TextureDataT | null)?.texture.destroy(), onGeoTIFFLoad: (_tiff, options) => { const { west, south, east, north } = options.geographicBounds; mapRef.current?.fitBounds( diff --git a/examples/vermont-cog-comparison/src/App.tsx b/examples/vermont-cog-comparison/src/App.tsx index e68c340c..e06ac7da 100644 --- a/examples/vermont-cog-comparison/src/App.tsx +++ b/examples/vermont-cog-comparison/src/App.tsx @@ -148,6 +148,8 @@ function makeCOGLayer(args: CogLayerArgs): COGLayer | null { epsgResolver, getTileData: useGrayLoader ? getTileDataGray : getTileDataRGBA, renderTile, + onTileUnload: (tile) => + (tile.data as TileTextureData | null)?.texture.destroy(), extensions: [new ClipExtension()], // @ts-expect-error clipBounds + clipByInstance + beforeId are injected // by ClipExtension and @deck.gl/mapbox; LayerProps doesn't know about From 2773812df4cbd84f19ede75522f3b980463e28dc Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:18:02 -0400 Subject: [PATCH 07/12] docs(examples): note texture ownership in zarr-sentinel2-tci (#591) Tile data is CPU ImageData; the GPU texture is created and owned by deck.gl's RasterLayer image-prop handling, so no onTileUnload cleanup is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/zarr-sentinel2-tci/src/App.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/zarr-sentinel2-tci/src/App.tsx b/examples/zarr-sentinel2-tci/src/App.tsx index cd4d10b2..b455f860 100644 --- a/examples/zarr-sentinel2-tci/src/App.tsx +++ b/examples/zarr-sentinel2-tci/src/App.tsx @@ -22,6 +22,12 @@ type SentinelTileData = MinimalTileData & { image: ImageData; }; +// Unlike the texture-uploading examples, this loader keeps tile data as CPU +// `ImageData` (garbage-collected normally) and lets `renderTile` return it via +// the RasterLayer `image` prop. deck.gl's `type: "image"` prop handling creates +// and owns the GPU texture, freeing it on update/unmount — so no `onTileUnload` +// texture cleanup is required here. + /** * Fetch one spatial chunk as an RGBA ImageData ready to upload as a texture. */ From 2902665f1284059dd716f8ed6f1274eeca7aecf2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:28:40 -0400 Subject: [PATCH 08/12] refactor: thread DataT through RasterTileLayerProps tile callbacks (#591) RasterTileLayerProps picked the tile-lifecycle callbacks from the non-generic TileLayerProps, collapsing tile data to `unknown` and forcing `tile.data as XTileData` casts in every onTileUnload. Pick from TileLayerProps instead so `tile.content` is typed `DataT | null`, and use the cast-free `tile.content?.texture.destroy()` in examples and the library's own COGLayer/MultiCOGLayer cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/aef-mosaic/src/App.tsx | 3 +-- examples/dynamical-zarr-ecmwf/src/App.tsx | 3 +-- examples/land-cover/src/App.tsx | 4 +--- examples/naip-mosaic/src/App.tsx | 3 +-- examples/nldas-icechunk/src/App.tsx | 3 +-- examples/usgs-topo-cutline/src/App.tsx | 3 +-- examples/vermont-cog-comparison/src/App.tsx | 3 +-- packages/deck.gl-geotiff/src/cog-layer.ts | 4 +++- packages/deck.gl-geotiff/src/multi-cog-layer.ts | 2 +- packages/deck.gl-geotiff/tests/cog-layer.test.ts | 8 ++++---- packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts | 6 +++--- .../src/raster-tile-layer/raster-tile-layer.ts | 2 +- 12 files changed, 19 insertions(+), 25 deletions(-) diff --git a/examples/aef-mosaic/src/App.tsx b/examples/aef-mosaic/src/App.tsx index 3cd3a80c..15a691ef 100644 --- a/examples/aef-mosaic/src/App.tsx +++ b/examples/aef-mosaic/src/App.tsx @@ -96,8 +96,7 @@ export default function App() { selection, getTileData, renderTile, - onTileUnload: (tile) => - (tile.data as AefTileData | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), minZoom: MIN_ZOOM, // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain diff --git a/examples/dynamical-zarr-ecmwf/src/App.tsx b/examples/dynamical-zarr-ecmwf/src/App.tsx index 05cf48f4..cef06e7a 100644 --- a/examples/dynamical-zarr-ecmwf/src/App.tsx +++ b/examples/dynamical-zarr-ecmwf/src/App.tsx @@ -215,8 +215,7 @@ export default function App() { selection, getTileData, renderTile, - onTileUnload: (tile) => - (tile.data as EcmwfTileData | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), // source.coop supports HTTP/2 multiplexing, so increase concurrent // requests beyond browser limit of 6 per HTTP/1.1 domain maxRequests: 20, diff --git a/examples/land-cover/src/App.tsx b/examples/land-cover/src/App.tsx index 7a2d4747..4bd94768 100644 --- a/examples/land-cover/src/App.tsx +++ b/examples/land-cover/src/App.tsx @@ -10,7 +10,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; import { InfoPanel } from "./components/InfoPanel.js"; -import type { LandCoverTileData } from "./get-tile-data.js"; import { getTileData } from "./get-tile-data.js"; import { buildColormapTexture } from "./nlcd/build-colormap-texture.js"; import { @@ -99,8 +98,7 @@ export default function App() { epsgResolver, getTileData, renderTile, - onTileUnload: (tile) => - (tile.data as LandCoverTileData | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), onGeoTIFFLoad: (tiff, options) => { setGeotiff(tiff); const { west, south, east, north } = options.geographicBounds; diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 1206491f..eee9a797 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -433,8 +433,7 @@ export default function App() { colormapIndex: colormapChoice.colormapIndex, colormapReversed: colormapChoice.reversed, }), - onTileUnload: (tile) => - (tile.data as TextureDataT | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), signal, }); }, diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index bbe03e92..5f87ec7a 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -132,8 +132,7 @@ export default function App() { rescaleMin, rescaleMax, }), - onTileUnload: (tile) => - (tile.data as NldasTileData | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), // Re-run renderTile on cached tiles when the colormap or rescale // range changes. updateTriggers: { diff --git a/examples/usgs-topo-cutline/src/App.tsx b/examples/usgs-topo-cutline/src/App.tsx index 7787368e..f8e38e52 100644 --- a/examples/usgs-topo-cutline/src/App.tsx +++ b/examples/usgs-topo-cutline/src/App.tsx @@ -182,8 +182,7 @@ export default function App() { geotiff: selected.url, getTileData, renderTile: (data) => renderTile(data, cutlineEnabled, selected.bbox), - onTileUnload: (tile) => - (tile.data as TextureDataT | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), onGeoTIFFLoad: (_tiff, options) => { const { west, south, east, north } = options.geographicBounds; mapRef.current?.fitBounds( diff --git a/examples/vermont-cog-comparison/src/App.tsx b/examples/vermont-cog-comparison/src/App.tsx index e06ac7da..74af2f8b 100644 --- a/examples/vermont-cog-comparison/src/App.tsx +++ b/examples/vermont-cog-comparison/src/App.tsx @@ -148,8 +148,7 @@ function makeCOGLayer(args: CogLayerArgs): COGLayer | null { epsgResolver, getTileData: useGrayLoader ? getTileDataGray : getTileDataRGBA, renderTile, - onTileUnload: (tile) => - (tile.data as TileTextureData | null)?.texture.destroy(), + onTileUnload: (tile) => tile.content?.texture.destroy(), extensions: [new ClipExtension()], // @ts-expect-error clipBounds + clipByInstance + beforeId are injected // by ClipExtension and @deck.gl/mapbox; LayerProps doesn't know about diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index ee35970b..f81d9bfd 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -367,7 +367,9 @@ export class COGLayer< } return (tile) => { onTileUnload?.(tile); - const data = tile.data as TextureDataT | null; + // `DataT` is generic here (no `texture`/`mask` on `MinimalTileData`), but + // the default pipeline always returns a `TextureDataT`. + const data = tile.content as TextureDataT | null; destroyIfTexture(data?.texture); destroyIfTexture(data?.mask); }; diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index b60bfc68..6cd0990d 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -629,7 +629,7 @@ export class MultiCOGLayer extends RasterTileLayer< // user-supplied `getTileData`), so it always frees them. return (tile) => { onTileUnload?.(tile); - const data = tile.data as MultiTileResult | null; + const data = tile.content; if (!data) { return; } diff --git a/packages/deck.gl-geotiff/tests/cog-layer.test.ts b/packages/deck.gl-geotiff/tests/cog-layer.test.ts index 53fe4cdd..2b951866 100644 --- a/packages/deck.gl-geotiff/tests/cog-layer.test.ts +++ b/packages/deck.gl-geotiff/tests/cog-layer.test.ts @@ -17,7 +17,7 @@ function unloadCallback(layer: COGLayer) { return ( layer as unknown as { _onTileUnloadCallback: () => - | ((tile: { data: unknown }) => void) + | ((tile: { content: unknown }) => void) | undefined; } )._onTileUnloadCallback(); @@ -37,7 +37,7 @@ describe("COGLayer._onTileUnloadCallback", () => { const texture = fakeTexture(); const mask = fakeTexture(); - const tile = { data: { texture: texture.texture, mask: mask.texture } }; + const tile = { content: { texture: texture.texture, mask: mask.texture } }; cb?.(tile); expect(texture.destroy).toHaveBeenCalledOnce(); @@ -52,10 +52,10 @@ describe("COGLayer._onTileUnloadCallback", () => { } as never); const cb = unloadCallback(layer); - expect(() => cb?.({ data: null })).not.toThrow(); + expect(() => cb?.({ content: null })).not.toThrow(); const texture = fakeTexture(); - cb?.({ data: { texture: texture.texture } }); + cb?.({ content: { texture: texture.texture } }); expect(texture.destroy).toHaveBeenCalledOnce(); }); diff --git a/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts index 96c5fabb..bc7317ee 100644 --- a/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts +++ b/packages/deck.gl-geotiff/tests/multi-cog-layer.test.ts @@ -16,7 +16,7 @@ function unloadCallback(layer: MultiCOGLayer) { return ( layer as unknown as { _onTileUnloadCallback: () => - | ((tile: { data: unknown }) => void) + | ((tile: { content: unknown }) => void) | undefined; } )._onTileUnloadCallback(); @@ -40,7 +40,7 @@ describe("MultiCOGLayer._onTileUnloadCallback", () => { ["a", { texture: texA.texture }], ["b", { texture: texB.texture }], ]); - const tile = { data: { bands } }; + const tile = { content: { bands } }; cb?.(tile); expect(texA.destroy).toHaveBeenCalledOnce(); @@ -51,6 +51,6 @@ describe("MultiCOGLayer._onTileUnloadCallback", () => { it("tolerates a tile with no data", () => { const layer = new MultiCOGLayer({ id: "multi", sources: {} } as never); const cb = unloadCallback(layer); - expect(() => cb?.({ data: null })).not.toThrow(); + expect(() => cb?.({ content: null })).not.toThrow(); }); }); diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index 3b076056..f6a57c3f 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -62,7 +62,7 @@ export type RasterTileLayerProps< DataT extends MinimalTileData = MinimalTileData, > = CompositeLayerProps & Pick< - TileLayerProps, + TileLayerProps, | "debounceTime" | "extent" | "maxCacheByteSize" From e6a56d03724d977147951de4b28c6b89c29c09aa Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:32:22 -0400 Subject: [PATCH 09/12] remove tiny helper --- packages/deck.gl-geotiff/src/cog-layer.ts | 13 +++++++------ packages/deck.gl-geotiff/src/multi-cog-layer.ts | 8 +++++--- packages/deck.gl-geotiff/src/texture-cleanup.ts | 15 --------------- 3 files changed, 12 insertions(+), 24 deletions(-) delete mode 100644 packages/deck.gl-geotiff/src/texture-cleanup.ts diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index f81d9bfd..92c268d1 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -21,16 +21,13 @@ import { metersPerUnit, parseWkt, } from "@developmentseed/proj"; -import type { Texture } from "@luma.gl/core"; +import { Texture } from "@luma.gl/core"; import proj4 from "proj4"; import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import type { TextureDataT } from "./geotiff/render-pipeline.js"; import { inferRenderPipeline } from "./geotiff/render-pipeline.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; -import { destroyIfTexture } from "./texture-cleanup.js"; - -export type { MinimalTileData } from "@developmentseed/deck.gl-raster"; type DefaultDataT = MinimalTileData & { texture: Texture; @@ -370,8 +367,12 @@ export class COGLayer< // `DataT` is generic here (no `texture`/`mask` on `MinimalTileData`), but // the default pipeline always returns a `TextureDataT`. const data = tile.content as TextureDataT | null; - destroyIfTexture(data?.texture); - destroyIfTexture(data?.mask); + if (data?.texture instanceof Texture) { + data?.texture.destroy(); + } + if (data?.mask instanceof Texture) { + data?.mask.destroy(); + } }; } } diff --git a/packages/deck.gl-geotiff/src/multi-cog-layer.ts b/packages/deck.gl-geotiff/src/multi-cog-layer.ts index 6cd0990d..7b50a574 100644 --- a/packages/deck.gl-geotiff/src/multi-cog-layer.ts +++ b/packages/deck.gl-geotiff/src/multi-cog-layer.ts @@ -48,12 +48,12 @@ import { metersPerUnit, parseWkt, } from "@developmentseed/proj"; -import type { Device, Texture, TextureFormat } from "@luma.gl/core"; +import type { Device, TextureFormat } from "@luma.gl/core"; +import { Texture } from "@luma.gl/core"; import proj4 from "proj4"; import { DEFAULT_CONCURRENCY_LIMITER } from "./default-concurrency-limiter.js"; import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js"; import { geoTiffToDescriptor } from "./geotiff-tileset.js"; -import { destroyIfTexture } from "./texture-cleanup.js"; /** * Color palette for debug overlays. @@ -634,7 +634,9 @@ export class MultiCOGLayer extends RasterTileLayer< return; } for (const band of data.bands.values()) { - destroyIfTexture(band.texture); + if (band.texture instanceof Texture) { + band.texture.destroy(); + } } }; } diff --git a/packages/deck.gl-geotiff/src/texture-cleanup.ts b/packages/deck.gl-geotiff/src/texture-cleanup.ts deleted file mode 100644 index a3111b55..00000000 --- a/packages/deck.gl-geotiff/src/texture-cleanup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Texture } from "@luma.gl/core"; - -/** - * Destroy `value` if it is a luma.gl {@link Texture}, freeing its GPU memory. - * - * A no-op for `undefined`, `null`, or any non-`Texture` value, so it is safe to - * call on optional fields (e.g. an absent mask) without guarding first. luma's - * `Texture.destroy()` is idempotent, so calling this twice on the same texture - * is harmless. - */ -export function destroyIfTexture(value: unknown): void { - if (value instanceof Texture) { - value.destroy(); - } -} From e54cda91ec51955cf22c3c1ddcbb3b4dd2f3d509 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:32:49 -0400 Subject: [PATCH 10/12] remove unneeded comment --- examples/zarr-sentinel2-tci/src/App.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/examples/zarr-sentinel2-tci/src/App.tsx b/examples/zarr-sentinel2-tci/src/App.tsx index b455f860..cd4d10b2 100644 --- a/examples/zarr-sentinel2-tci/src/App.tsx +++ b/examples/zarr-sentinel2-tci/src/App.tsx @@ -22,12 +22,6 @@ type SentinelTileData = MinimalTileData & { image: ImageData; }; -// Unlike the texture-uploading examples, this loader keeps tile data as CPU -// `ImageData` (garbage-collected normally) and lets `renderTile` return it via -// the RasterLayer `image` prop. deck.gl's `type: "image"` prop handling creates -// and owns the GPU texture, freeing it on update/unmount — so no `onTileUnload` -// texture cleanup is required here. - /** * Fetch one spatial chunk as an RGBA ImageData ready to upload as a texture. */ From 195cf2558f00d8982d10b2b6f59d2608d6c6a8c5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:33:41 -0400 Subject: [PATCH 11/12] remove test file --- .../tests/texture-cleanup.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 packages/deck.gl-geotiff/tests/texture-cleanup.test.ts diff --git a/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts b/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts deleted file mode 100644 index 12309d47..00000000 --- a/packages/deck.gl-geotiff/tests/texture-cleanup.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Texture } from "@luma.gl/core"; -import { describe, expect, it, vi } from "vitest"; -import { destroyIfTexture } from "../src/texture-cleanup.js"; - -/** - * A value that passes `instanceof Texture` (shares Texture's prototype) plus a - * spy `destroy`. We can't `new Texture()` (it is abstract / needs a device), so - * we synthesize the prototype link directly. - */ -function fakeTexture(): { - texture: Texture; - destroy: ReturnType; -} { - const destroy = vi.fn(); - const texture = Object.create(Texture.prototype) as Texture; - Object.defineProperty(texture, "destroy", { value: destroy }); - return { texture, destroy }; -} - -describe("destroyIfTexture", () => { - it("destroys a value that is a Texture", () => { - const { texture, destroy } = fakeTexture(); - destroyIfTexture(texture); - expect(destroy).toHaveBeenCalledOnce(); - }); - - it("ignores undefined", () => { - expect(() => destroyIfTexture(undefined)).not.toThrow(); - }); - - it("ignores null", () => { - expect(() => destroyIfTexture(null)).not.toThrow(); - }); - - it("does not destroy a non-Texture object that happens to have destroy()", () => { - const notTexture = { destroy: vi.fn() }; - destroyIfTexture(notTexture); - expect(notTexture.destroy).not.toHaveBeenCalled(); - }); -}); From fba4d75b5508197aa32d2d96ab6df4d3ad48778e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 5 Jun 2026 15:55:36 -0400 Subject: [PATCH 12/12] remove unnecessary type cast --- .../deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts index f6a57c3f..1f487fae 100644 --- a/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts +++ b/packages/deck.gl-raster/src/raster-tile-layer/raster-tile-layer.ts @@ -233,7 +233,7 @@ export class RasterTileLayer< * no textures, so it returns the user's callback unchanged. */ protected _onTileUnloadCallback(): RasterTileLayerProps["onTileUnload"] { - return (this.props as unknown as RasterTileLayerProps).onTileUnload; + return this.props.onTileUnload; } /**