feat(widgets): Add controlled mode and state callbacks#9973
Merged
chrisgervang merged 26 commits intomasterfrom Apr 7, 2026
Merged
feat(widgets): Add controlled mode and state callbacks#9973chrisgervang merged 26 commits intomasterfrom
chrisgervang merged 26 commits intomasterfrom
Conversation
Implement controlled/uncontrolled component pattern for 12 widgets (ThemeWidget, FullscreenWidget, TimelineWidget, ZoomWidget, CompassWidget, GimbalWidget, GeocoderWidget, ResetViewWidget, ViewSelectorWidget, SplitterWidget, StatsWidget, LoadingWidget). Add state props, getter methods, and callbacks. Update widget overview documentation with controlled/uncontrolled mode explanation. Closes #9964 Co-Authored-By: Claude (global.anthropic.claude-haiku-4-5-20251001-v1:0) <noreply@anthropic.com>
Resolve conflict in stats-widget.tsx: keep _collapsed naming for controlled mode internal state, use _getStats() method from master. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove redundant _playing assignments in timeline-widget start()/stop() - Remove redundant updateHTML() calls after super.setProps() in multiple widgets - Fix IconMenu to support controlled mode via selectedItem prop Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Resolve conflicts in: - docs/api-reference/widgets/stats-widget.md: Merged documentation formats, added controlled mode props - modules/widgets/src/reset-view-widget.tsx: Combined multi-view loop with onReset callback - modules/widgets/src/stats-widget.tsx: Used dropdown button with corrected variable name Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ibgreen
approved these changes
Apr 1, 2026
Collaborator
ibgreen
left a comment
There was a problem hiding this comment.
I gave this some additional thought and I think supporting application control is a good direction as we build out a more comprehensive UI.
Resolve merge conflicts between widget state callbacks feature and master's widget refactoring (getViewState/setViewState API, ViewLayout for splitter, RangeInput for timeline, file deletions). - compass-widget: Integrate onCompassReset callback with master's getViewState pattern - geocoder-widget: Integrate onGeocode callback with master's flyTo pattern - reset-view-widget: Integrate onReset callback with master's setViewState - splitter-widget: Take master's ViewLayout rewrite (branch's controlled split doesn't apply) - stats-widget: Merge master's FpsIcon collapsed view with branch's controlled collapsed prop - timeline-widget: Merge master's formatLabel/loop/RangeInput with branch's controlled time/playing - zoom-widget: Integrate onZoom callback with master's getViewState/setViewState pattern - Removed view-selector-widget and icon-menu (deleted in master) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Rename onCompassReset -> onReset (CompassWidget) - Rename onGimbalReset -> onReset (GimbalWidget) - Rename defaultIsExpanded -> initialExpanded (StatsWidget, aligns with initial* convention) - Rename collapsed/onCollapsedChange -> expanded/onExpandedChange (StatsWidget, positive flag) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eWidget - play() now respects controlled time mode by guarding internal state update - tick() now syncs Timeline object in uncontrolled mode - setProps() now syncs Timeline object when controlled time prop changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eWidget - Fix pseudo-fullscreen path in FullscreenWidget not updating state/UI (no fullscreenchange event fires, so manually sync state and re-render) - Fix controlled playing mode infinite timer loop in TimelineWidget (call onPlayingChange(false) when playback naturally ends at max) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover: - TimelineWidget: controlled/uncontrolled time, playing state, tick end-of-playback, Timeline sync, play() controlled mode guard - StatsWidget: initialExpanded, controlled/uncontrolled expanded toggle - FullscreenWidget: controlled/uncontrolled state, pseudo-fullscreen path - CompassWidget/GimbalWidget: onReset callback prop wiring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename from .node.spec.ts to .spec.ts so tests run in headless browser (standard practice for deck.gl widget tests). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pessimistress
approved these changes
Apr 2, 2026
…ebsite demo - Use this._playing instead of this.getPlaying() for timer scheduling in tick(), so the timer stops immediately when playback ends naturally, even before the parent updates the controlled playing prop. - Update website demo to use initialExpanded (renamed from defaultIsExpanded). - Add test for controlled-mode end-of-playback timer behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nWidget controlled state - Add "React Controlled" example tabs to Compass, Gimbal, Zoom (managed viewState), Timeline, Stats, Theme (managed widget state), and Splitter (managed views) widget docs - Remove FullscreenWidget `fullscreen` controlled prop since it cannot actually control browser fullscreen state - Remove corresponding controlled-mode tests for FullscreenWidget Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Unit tests (45 total, up from 23): - ThemeWidget: controlled/uncontrolled toggle, initialThemeMode, getThemeMode - ZoomWidget: onZoom callback, handleZoom with minZoom/maxZoom constraints - GimbalWidget: resetOrbitView calls onReset with correct params - LoadingWidget: onRedraw calls onLoadingChange on state transitions - GeocoderWidget: flyTo calls onGeocode with coordinates - ResetViewWidget: resetViewState calls onReset for each view - TimelineWidget: loop wrap-around behavior - FullscreenWidget: onFullscreenChange event handler Example app (examples/get-started/react/controlled-widgets): - All controlled widgets on one page with live state panel - MapView demo: Compass, Zoom, ResetView, Fullscreen, Theme, Loading, Stats (controlled expanded), Timeline (controlled time + playing) - Splitter demo: managed views with onChange Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lled mode In controlled mode, the widget can't atomically reset time and start playing since those are separate React state updates. The restart-from- beginning logic now belongs to the app's onPlayingChange handler. Also switches test app from JSX widget children to widgets prop, adds test matrix README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5221f14. Configure here.
In controlled mode, autoPlay now calls onPlayingChange(true) to notify the parent instead of directly starting the timer, which would desync internal state from the controlled playing prop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clarify that in controlled mode the app is the sole source of truth for time and playing state. Document that autoPlay fires onPlayingChange in controlled mode, and that restart-from-beginning is the app's responsibility. Update React Controlled example to demonstrate the pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… time prop The constructor was setting the luma.gl Timeline to initialTime/timeRange[0] even when a controlled time prop was provided. Also fixes autoPlay in controlled mode to notify parent via onPlayingChange instead of directly starting the timer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ange Restores the guard that prevents spurious onFullscreenChange callbacks and re-renders when the fullscreenchange event fires for unrelated elements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test was calling onFullscreenChange when fullscreen was already false, so the guard correctly skipped the callback. Fixed by setting fullscreen=true first, and added a test for the no-op case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Implements the controlled/uncontrolled component pattern for deck.gl widgets, enabling app developers to control, observe, and intercept user interactions. Adds controlled state props, getter methods, and event callbacks.
Closes #9964
Widget Additions
time,playingonTimeChange,onPlayingChangegetTime(),getPlaying()themeModeonThemeModeChangegetThemeMode()expandedonExpandedChangegetExpanded()onFullscreenChangegetFullscreen()onResetonResetonResetonZoomonGeocodeonLoadingChangeonChangeWidgets with controlled state support both controlled mode (app owns the state via props) and uncontrolled mode (widget manages its own state internally, with
initial*props for defaults). In controlled mode, the app is the sole source of truth — the widget fires callbacks but does not update internal state.Other Changes
defaultIsExpanded→initialExpandedin StatsWidget to align withinitial*conventioncollapsed/onCollapsedChange→expanded/onExpandedChange(positive flag naming)autoPlayrespects controlledplayingprop (firesonPlayingChangeinstead of starting timer directly)play()only auto-resets time to start in uncontrolled mode; in controlled mode the app handles restart logictest/apps/controlled-widgets/manual test app with test matrix READMEwidget-state.spec.ts)Test Plan
widget-state.spec.tscovering controlled/uncontrolled state for all widgetstest/apps/controlled-widgets/README.md(24 scenarios, all passing)