From 95a3574d73829aec8752482f98b991dce8d1acf2 Mon Sep 17 00:00:00 2001 From: Adam Krebs Date: Wed, 10 Jun 2026 01:32:02 -0400 Subject: [PATCH] feat(core): add Deck.waitForFrameReady() Promise API Adds a public method on the Deck class that resolves once all pending updates have settled and a frame has been rendered. Useful for headless capture, video export, and any flow that needs to know the next canvas read will reflect a fully-settled scene. Settled means: - All layers report `layer.isLoaded === true` - LayerManager.needsUpdate() returns false - Deck.needsRedraw() returns false - An onAfterRender cycle has completed since the call started (skipped when the scene is already settled at call time) Options: - timeout (default 5000 ms): rejects with an Error if exceeded - checkLayers (default true): include the per-layer isLoaded check - checkAttributes (default true): include the LayerManager.needsUpdate check The implementation chains a one-shot frame-completion handler over the user-provided onAfterRender and restores the original handler before resolving or rejecting, so existing render hooks continue to fire. Motivating use case: noodles.gl video export currently relies on a "skip first render + 16ms timeout" heuristic to avoid stale data; a deterministic Promise API lets the exporter wait for actual readiness. Tests cover the happy path, async-data settle, timeout rejection, checkLayers=false escape hatch, the not-initialized error case, and that the user-provided onAfterRender is preserved across the wait. --- docs/api-reference/core/deck.md | 37 +++++ modules/core/src/lib/deck.ts | 116 ++++++++++++++++ test/modules/core/lib/deck.spec.ts | 212 +++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+) diff --git a/docs/api-reference/core/deck.md b/docs/api-reference/core/deck.md index e1e34bf66e1..ef87bf2abaf 100644 --- a/docs/api-reference/core/deck.md +++ b/docs/api-reference/core/deck.md @@ -690,6 +690,43 @@ Parameters: * `force` (boolean) - if `false`, only redraw if necessary (e.g. changes have been made to views or layers). If `true`, skip the check. Default `false`. +#### `waitForFrameReady` {#waitforframeready} + +Wait until all pending updates have settled and a frame has been rendered. Useful for headless capture, video export, or any flow that needs to read back canvas pixels and must know that the next read will reflect a fully-settled scene. + +The returned Promise resolves once: + +* All layers report `layer.isLoaded === true` (no pending async props or resources) +* The layer manager has no pending updates (`needsUpdate() === false`) +* No redraw is queued (`needsRedraw() === false`) +* If the scene was not already settled, an `onAfterRender` cycle has completed since the call started + +If the deadline passes before the scene settles, the Promise rejects with an `Error`. + +```ts +const result = await deck.waitForFrameReady({timeout: 5000}); +// result: {layersReady: boolean, attributesReady: boolean, duration: number} +``` + +Parameters: + +* `options.timeout` (number, optional) - maximum wait time in milliseconds before the Promise rejects. Default `5000`. +* `options.checkLayers` (boolean, optional) - if `false`, skip the per-layer `isLoaded` check. Default `true`. +* `options.checkAttributes` (boolean, optional) - if `false`, skip the layer-manager `needsUpdate` check. Default `true`. + +Returns: + +* A Promise that resolves with an object describing the final state: + + `layersReady` (boolean) - whether all layers reported loaded + + `attributesReady` (boolean) - whether the attribute manager reported settled + + `duration` (number) - elapsed time in milliseconds + +Notes: + +* `waitForFrameReady` does not force a redraw on its own. If you need to ensure a render happens, call `setProps`, mutate `layers`, or call `redraw('forced')` before/while awaiting. +* The implementation chains its own `onAfterRender` handler over the user-provided one and restores the original handler before resolving or rejecting. + + #### `pickObjectAsync` {#pickobjectasync} Get the closest pickable and visible object at the given screen coordinate. diff --git a/modules/core/src/lib/deck.ts b/modules/core/src/lib/deck.ts index 9cff26cb0e9..c5e4320460b 100644 --- a/modules/core/src/lib/deck.ts +++ b/modules/core/src/lib/deck.ts @@ -628,6 +628,122 @@ export default class Deck { } } + /** + * Wait until all pending updates have settled and a frame has been rendered. + * + * Resolves when: + * - All layers report `isLoaded === true` (no pending async props/resources) + * - The layer manager has no pending updates (`needsUpdate() === false`) + * - No redraw is queued (`needsRedraw() === false`) + * - An `onAfterRender` cycle has completed since the call started + * + * This is intended for use cases such as headless capture / video export where + * a caller needs to know that the next read of the canvas will reflect a + * fully-settled scene. + * + * @param options.timeout Maximum wait time in ms before rejecting. Default 5000. + * @param options.checkLayers If false, skip the per-layer `isLoaded` check. Default true. + * @param options.checkAttributes If false, skip the layer-manager `needsUpdate` check. Default true. + * @returns Resolves with a status object describing the final state and elapsed time. + * Rejects with an Error if the deadline is reached before settle. + */ + async waitForFrameReady(options?: { + timeout?: number; + checkLayers?: boolean; + checkAttributes?: boolean; + }): Promise<{ + layersReady: boolean; + attributesReady: boolean; + duration: number; + }> { + const {timeout = 5000, checkLayers = true, checkAttributes = true} = options || {}; + + if (!this.layerManager) { + throw new Error('Deck.waitForFrameReady: Deck is not initialized'); + } + + const start = Date.now(); + const deadline = start + timeout; + + const layersAreReady = (): boolean => { + if (!checkLayers) return true; + const layers = this.layerManager!.getLayers(); + for (const layer of layers) { + if (!layer.isLoaded) return false; + } + return true; + }; + + const attributesAreReady = (): boolean => { + if (!checkAttributes) return true; + return this.layerManager!.needsUpdate() === false; + }; + + const sceneIsSettled = (): boolean => { + if (!layersAreReady()) return false; + if (!attributesAreReady()) return false; + if (this.needsRedraw({clearRedrawFlags: false})) return false; + return true; + }; + + // If the scene is already settled, resolve immediately - no need to wait for a frame. + if (sceneIsSettled()) { + return { + layersReady: layersAreReady(), + attributesReady: attributesAreReady(), + duration: Date.now() - start + }; + } + + // Capture the user-installed onAfterRender once; we'll wrap it with a chained + // handler that signals each completed frame, and restore it before returning. + const userOnAfterRender = this.props.onAfterRender; + const signalState: {pending: (() => void) | null} = {pending: null}; + this.setProps({ + onAfterRender: (ctx: {device: Device; gl: WebGL2RenderingContext}) => { + userOnAfterRender?.(ctx); + const signal = signalState.pending; + signalState.pending = null; + signal?.(); + } + }); + + const waitOneFrame = (remaining: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signalState.pending = null; + reject(new Error(`Deck.waitForFrameReady: timed out after ${timeout}ms`)); + }, remaining); + signalState.pending = () => { + clearTimeout(timer); + resolve(); + }; + }); + + try { + for (;;) { + if (Date.now() > deadline) { + throw new Error( + `Deck.waitForFrameReady: timed out after ${timeout}ms (` + + `layersReady=${layersAreReady()}, attributesReady=${attributesAreReady()})` + ); + } + + await waitOneFrame(Math.max(0, deadline - Date.now())); + + if (sceneIsSettled()) { + return { + layersReady: layersAreReady(), + attributesReady: attributesAreReady(), + duration: Date.now() - start + }; + } + } + } finally { + this.setProps({onAfterRender: userOnAfterRender}); + } + } + /** Flag indicating that the Deck instance has initialized its resources and it's safe to call public methods. */ get isInitialized(): boolean { return this.viewManager !== null; diff --git a/test/modules/core/lib/deck.spec.ts b/test/modules/core/lib/deck.spec.ts index 2e3702d1f9d..1ff90e35022 100644 --- a/test/modules/core/lib/deck.spec.ts +++ b/test/modules/core/lib/deck.spec.ts @@ -887,6 +887,218 @@ test('Deck#getView with multiple views', async () => { }); }); +test('Deck#waitForFrameReady resolves after a render with no pending updates', async () => { + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [ + new ScatterplotLayer({ + id: 'sync-layer', + data: [{position: [0, 0]}], + getPosition: d => d.position, + getRadius: 1, + getFillColor: [255, 0, 0] + }) + ] + }); + + try { + await waitForRender(deck); + + const result = await deck.waitForFrameReady({timeout: 2000}); + expect(result.layersReady, 'layers report ready').toBe(true); + expect(result.attributesReady, 'attributes report ready').toBe(true); + expect(typeof result.duration, 'duration is a number').toBe('number'); + expect(result.duration >= 0, 'duration is non-negative').toBe(true); + } finally { + deck.finalize(); + } +}); + +test('Deck#waitForFrameReady waits for an async layer prop to settle', async () => { + const deferred = createDeferred<{position: number[]}[]>(); + + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [ + new ScatterplotLayer({ + id: 'async-layer', + data: deferred.promise, + getPosition: d => d.position, + getRadius: 1, + getFillColor: [0, 255, 0] + }) + ] + }); + + try { + await waitForRender(deck); + + const waiter = deck.waitForFrameReady({timeout: 5000}); + + let waiterResolved = false; + waiter.then(() => { + waiterResolved = true; + }); + + await sleep(50); + expect(waiterResolved, 'waiter is still pending while data unresolved').toBe(false); + + deferred.resolve([{position: [0, 0]}]); + + const result = await waiter; + expect(result.layersReady, 'layers report ready after data resolves').toBe(true); + expect(result.attributesReady, 'attributes report ready after data resolves').toBe(true); + } finally { + deck.finalize(); + } +}); + +test('Deck#waitForFrameReady rejects when timeout elapses with pending data', async () => { + const deferred = createDeferred<{position: number[]}[]>(); + + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [ + new ScatterplotLayer({ + id: 'never-loaded', + data: deferred.promise, + getPosition: d => d.position + }) + ] + }); + + try { + await waitForRender(deck); + + let caught: Error | null = null; + try { + await deck.waitForFrameReady({timeout: 100}); + } catch (err) { + caught = err as Error; + } + expect(caught, 'waitForFrameReady rejects on timeout').toBeTruthy(); + expect(caught?.message.includes('timed out'), 'error mentions timeout').toBe(true); + } finally { + deferred.resolve([]); + deck.finalize(); + } +}); + +test('Deck#waitForFrameReady checkLayers=false skips per-layer readiness', async () => { + const deferred = createDeferred<{position: number[]}[]>(); + + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [ + new ScatterplotLayer({ + id: 'pending-layer', + data: deferred.promise, + getPosition: d => d.position + }) + ] + }); + + try { + await waitForRender(deck); + const result = await deck.waitForFrameReady({timeout: 2000, checkLayers: false}); + expect(result.attributesReady, 'attributes are ready').toBe(true); + } finally { + deferred.resolve([]); + deck.finalize(); + } +}); + +test('Deck#waitForFrameReady throws if Deck is not initialized', async () => { + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [] + }); + deck.finalize(); + + let caught: Error | null = null; + try { + await deck.waitForFrameReady(); + } catch (err) { + caught = err as Error; + } + expect(caught, 'rejects when not initialized').toBeTruthy(); + expect(caught?.message.includes('not initialized'), 'message mentions initialization').toBe(true); +}); + +test('Deck#waitForFrameReady preserves user-provided onAfterRender', async () => { + let userCallCount = 0; + const deferred = createDeferred<{position: number[]}[]>(); + const userOnAfterRender = () => { + userCallCount++; + }; + + const deck = new Deck({ + device, + width: 1, + height: 1, + viewState: {longitude: 0, latitude: 0, zoom: 0}, + layers: [ + new ScatterplotLayer({ + id: 'restore-layer', + data: deferred.promise, + getPosition: d => d.position + }) + ], + onAfterRender: userOnAfterRender + }); + + try { + // Use a one-shot resolve via direct setProps so we don't pollute the user's handler. + await new Promise(resolve => { + deck.setProps({ + onAfterRender: ctx => { + userOnAfterRender(); + deck.setProps({onAfterRender: userOnAfterRender}); + resolve(); + } + }); + }); + + // Confirm starting state: deck's onAfterRender is the user's function. + expect(deck.props.onAfterRender, 'precondition: user handler installed').toBe( + userOnAfterRender + ); + + // Start the wait - data is pending, so it will install our chained handler. + const waiter = deck.waitForFrameReady({timeout: 2000}); + const beforeData = userCallCount; + + deferred.resolve([{position: [0, 0]}]); + await waiter; + + // While resolving, at least one frame fired and the chained handler should have + // invoked the user callback. + expect(userCallCount > beforeData, 'user onAfterRender invoked during wait').toBe(true); + + // After the wait, the user's original handler should still be in place. + expect(deck.props.onAfterRender, 'user onAfterRender restored after wait').toBe( + userOnAfterRender + ); + } finally { + deck.finalize(); + } +}); + test('Deck#props omitted are unchanged', async () => { const layer = new ScatterplotLayer({ id: 'scatterplot-global-data',