Skip to content

Commit da1600b

Browse files
chrisgervangclaude
andcommitted
fix(react): Defer onLoad to ensure React widgets are available
- Intercept onLoad from Deck and defer it until after React widget children have registered via useWidget - Use setTimeout(0) to escape React's commit phase, allowing callbacks to safely trigger state updates or nested act() calls - Update tests to import act from 'react' instead of deprecated 'react-dom/test-utils' - Use shared gl context from @deck.gl/test-utils for all React tests This ensures widgets are available on deck.props.widgets when the onLoad callback fires, fixing the issue where onLoad fired before React children had a chance to render and register their widgets. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
1 parent 87036c2 commit da1600b

2 files changed

Lines changed: 42 additions & 42 deletions

File tree

modules/react/src/deckgl.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
124124
const canvasRef = useRef(null);
125125
// Stable widgets array for React widget components (survives StrictMode remounts)
126126
const widgetsRef = useRef<Widget[]>([]);
127+
// Track deferred onLoad - use state to trigger re-render when deck initializes
128+
const [onLoadPending, setOnLoadPending] = useState(false);
129+
const onLoadCalledRef = useRef(false);
127130

128131
// extract any deck.gl layers masquerading as react elements from props.children
129132
const jsxProps = useMemo(
@@ -158,6 +161,14 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
158161
}
159162
};
160163

164+
// Defer onLoad until after React widget children have had a chance to register.
165+
// Deck's onLoad fires during initialization, before React children render.
166+
// By using state to track pending status, we trigger a re-render when deck initializes,
167+
// then call onLoad in useEffect after children have rendered and registered widgets.
168+
const handleOnLoad: DeckProps<ViewsT>['onLoad'] = () => {
169+
setOnLoadPending(true);
170+
};
171+
161172
// Update Deck's props. If Deck needs redraw, this will trigger a call to `_customRender` in
162173
// the next animation frame.
163174
// Needs to be called both from initial mount, and when new props are received
@@ -174,7 +185,10 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
174185
layers: jsxProps.layers,
175186
views: jsxProps.views as ViewsT,
176187
onViewStateChange: handleViewStateChange,
177-
onInteractionStateChange: handleInteractionStateChange
188+
onInteractionStateChange: handleInteractionStateChange,
189+
// Always provide onLoad handler - Deck expects it to be a function.
190+
// The deferred effect will only call the user's callback if they provided one.
191+
onLoad: handleOnLoad
178192
};
179193

180194
// The defaultValue for _customRender is null, which would overwrite the definition
@@ -200,6 +214,24 @@ function DeckGLWithRef<ViewsT extends ViewOrViews = null>(
200214
return () => thisRef.deck?.finalize();
201215
}, []);
202216

217+
// Stable reference to onLoad callback for use in deferred effect
218+
const onLoadRef = useRef(props.onLoad);
219+
onLoadRef.current = props.onLoad;
220+
221+
// Call deferred onLoad after React widget children have registered.
222+
// React guarantees parent effects run after children effects, so by this point
223+
// any widgets using useWidget will have synced to deck.
224+
// Use setTimeout(0) to escape React's commit phase and act() scope, allowing
225+
// the callback to safely trigger state updates or nested act() calls in tests.
226+
useEffect(() => {
227+
if (onLoadPending && !onLoadCalledRef.current) {
228+
onLoadCalledRef.current = true;
229+
setTimeout(() => {
230+
onLoadRef.current?.();
231+
}, 0);
232+
}
233+
}, [onLoadPending]);
234+
203235
useIsomorphicLayoutEffect(() => {
204236
// render has just been called. The children are positioned based on the current view state.
205237
// Redraw Deck canvas immediately, if necessary, using the current view state, so that it

test/modules/react/deckgl.spec.ts

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@
44

55
/* eslint-disable no-unused-vars */
66
import test from 'tape-promise/tape';
7-
import {StrictMode, createElement, createRef} from 'react';
7+
import {StrictMode, createElement, createRef, act} from 'react';
88
import {createRoot} from 'react-dom/client';
9-
import {act} from 'react-dom/test-utils';
109

1110
import {DeckGL, Layer, Widget} from 'deck.gl';
1211
import {useWidget} from '@deck.gl/react';
1312
import {type WidgetProps, type WidgetPlacement} from '@deck.gl/core';
14-
1513
import {gl} from '@deck.gl/test-utils';
1614

1715
const TEST_VIEW_STATE = {
@@ -22,10 +20,7 @@ const TEST_VIEW_STATE = {
2220
pitch: 45
2321
};
2422

25-
// Reuse shared WebGL context from test-utils to avoid context exhaustion
26-
// gl is already set up by test-utils for both Node (headless) and browser
2723
/* global document */
28-
const getTestContext = () => gl;
2924

3025
test('DeckGL#mount/unmount', t => {
3126
const ref = createRef();
@@ -40,7 +35,7 @@ test('DeckGL#mount/unmount', t => {
4035
ref,
4136
width: 100,
4237
height: 100,
43-
gl: getTestContext(),
38+
gl: gl,
4439
onLoad: () => {
4540
const {deck} = ref.current;
4641
t.ok(deck, 'DeckGL is initialized');
@@ -73,7 +68,7 @@ test('DeckGL#render', t => {
7368
viewState: TEST_VIEW_STATE,
7469
width: 100,
7570
height: 100,
76-
gl: getTestContext(),
71+
gl: gl,
7772
onAfterRender: () => {
7873
const child = container.querySelector('.child');
7974
t.ok(child, 'Child is rendered');
@@ -123,7 +118,7 @@ test('DeckGL#props omitted are reset', t => {
123118
ref,
124119
width: 100,
125120
height: 100,
126-
gl: getTestContext(),
121+
gl: gl,
127122
layers: LAYERS,
128123
widgets: WIDGETS,
129124
onLoad: () => {
@@ -187,9 +182,6 @@ test('useWidget#StrictMode cleanup removes duplicate widgets', t => {
187182
const container = document.createElement('div');
188183
document.body.append(container);
189184
const root = createRoot(container);
190-
let testCompleted = false;
191-
let renderCount = 0;
192-
const MAX_RENDERS = 100; // Safety limit to prevent infinite loop
193185

194186
act(() => {
195187
root.render(
@@ -203,41 +195,17 @@ test('useWidget#StrictMode cleanup removes duplicate widgets', t => {
203195
ref,
204196
width: 100,
205197
height: 100,
206-
gl: getTestContext(),
207-
// Use onAfterRender instead of onLoad because React widget children
208-
// can only render after deck exists, and onLoad fires before that.
209-
// Widget children need multiple render cycles to be positioned and registered:
210-
// 1. First render: deck doesn't exist yet, children can't be positioned
211-
// 2. useEffect creates deck
212-
// 3. deck triggers forceUpdate, React re-renders
213-
// 4. Children are now positioned and widget component can render
214-
// 5. useWidget registers the widget and syncs to deck
215-
onAfterRender: () => {
216-
if (testCompleted) {
217-
return;
218-
}
219-
220-
renderCount++;
198+
gl: gl,
199+
// onLoad is deferred by the React wrapper until after widget children
200+
// have registered, so widgets are available when this callback fires.
201+
onLoad: () => {
221202
const deck = ref.current?.deck;
222203
const widgets = deck?.props.widgets;
223204

224-
// Wait for widget to render - children can only render after deck exists
225-
// and React needs additional render cycles to position them
226-
if (!deck || !widgets?.length) {
227-
if (renderCount >= MAX_RENDERS) {
228-
t.fail('Widget did not render after maximum render attempts');
229-
act(() => root.render(null));
230-
container.remove();
231-
t.end();
232-
}
233-
return;
234-
}
235-
236-
testCompleted = true;
237205
t.ok(deck, 'DeckGL is initialized');
238206
t.is(widgets?.length, 1, 'Only one widget instance remains after StrictMode remount');
239207

240-
// Clean up - don't wrap in act() to avoid nested act() errors
208+
// Clean up
241209
root.render(null);
242210
container.remove();
243211
t.end();

0 commit comments

Comments
 (0)