| # Issue #156985: Scroll Events Bubble to Parent Page When Flutter is Embedded in iframe |
| |
| ## Problem Description |
| |
| When a Flutter web app is embedded in an iframe, scroll events (mouse wheel) inside the Flutter app bubble up to the parent/host page, causing both the Flutter content and the parent page to scroll simultaneously. |
| |
| ### User Experience Impact |
| - Users scrolling through Flutter content also inadvertently scroll the parent page |
| - Creates a jarring, confusing experience |
| - Makes embedded Flutter apps feel broken compared to native iframe content |
| |
| ### Reproduction Steps |
| 1. Embed a Flutter web app in an iframe on a scrollable HTML page |
| 2. Scroll inside the Flutter app using mouse wheel |
| 3. Observe that the parent page also scrolls |
| |
| ## Root Cause Analysis |
| |
| The issue stems from how browsers handle scroll event propagation: |
| |
| 1. **Native Browser Scroll Chaining**: By default, when an element inside an iframe reaches its scroll boundary, the browser propagates the scroll to the parent document ("scroll chaining") |
| |
| 2. **Flutter's `allowPlatformDefault`**: Flutter uses `event.respond(allowPlatformDefault: true/false)` to control whether the browser should handle scroll events. When a scrollable is at its boundary, it sets `allowPlatformDefault: true`, which allows the browser's native scroll chaining to occur. |
| |
| 3. **Nested Scrollables Problem**: With nested scrollables (e.g., ListView inside SingleChildScrollView), when the inner scrollable hits its boundary, it says "allow platform default". But the outer scrollable might still be able to scroll! The engine was using `|=` to combine responses, so once ANY widget said "allow", it would stay "allow". |
| |
| ## Solution |
| |
| ### Engine Changes (`pointer_binding.dart`) |
| |
| 1. **Always `preventDefault()` in iframes**: When Flutter detects it's running inside an iframe, it always calls `preventDefault()` on wheel events to block native scroll chaining. |
| |
| 2. **New flag `_lastWheelEventHandledByWidget`**: Tracks whether ANY widget explicitly handled the scroll event (responded with `allowPlatformDefault: false`). |
| |
| 3. **Explicit parent scrolling via `postMessage`**: When ALL Flutter scrollables are at their boundaries (no widget handled the event), the engine explicitly scrolls the parent page using `window.parent.postMessage()`. |
| |
| 4. **Iframe detection**: Added `_isEmbeddedInIframe()` method to detect if Flutter is running inside an iframe by checking if `window.parent !== window`. |
| |
| ```dart |
| // Key logic in _handleWheelEvent |
| final bool isInIframe = _isEmbeddedInIframe(); |
| if (!_lastWheelEventAllowedDefault || isInIframe) { |
| event.preventDefault(); // Block native scroll chaining |
| } |
| |
| // Only scroll parent when ALL scrollables are at boundary |
| final bool shouldScrollParent = isInIframe && |
| _lastWheelEventAllowedDefault && |
| !_lastWheelEventHandledByWidget; |
| if (shouldScrollParent) { |
| scrollParentWindow(wheelEvent.deltaX, wheelEvent.deltaY); |
| } |
| ``` |
| |
| ### Framework Changes (`scrollable.dart`) |
| |
| Added explicit `respond(allowPlatformDefault: false)` when a scrollable successfully handles a scroll event: |
| |
| ```dart |
| void _handlePointerScroll(PointerEvent event) { |
| // ... scroll handling ... |
| if (delta != 0.0 && targetScrollOffset != position.pixels) { |
| position.pointerScroll(delta); |
| // Tell engine this scrollable handled the event |
| scrollEvent.respond(allowPlatformDefault: false); |
| } |
| } |
| ``` |
| |
| ### Engine Changes (`dom.dart`) |
| |
| Added `scrollParentWindow()` function to scroll the parent page via `postMessage`: |
| |
| ```dart |
| void scrollParentWindow(double deltaX, double deltaY) { |
| final JSAny jsMessage = <String, dynamic>{ |
| 'type': 'flutter-scroll', |
| 'deltaX': deltaX, |
| 'deltaY': deltaY, |
| }.jsify()!; |
| domWindow.parent._postMessage(jsMessage, '*'); |
| } |
| ``` |
| |
| ## Files Changed |
| |
| | File | Change | |
| |------|--------| |
| | `engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart` | Iframe detection, preventDefault in iframe, parent scroll via postMessage | |
| | `engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart` | Added `scrollParentWindow()`, `parent` property on DomWindow | |
| | `packages/flutter/lib/src/widgets/scrollable.dart` | Added `respond(allowPlatformDefault: false)` when scroll handled | |
| |
| ## Host Page Requirements |
| |
| The host page must listen for `flutter-scroll` messages to receive scroll propagation: |
| |
| ```javascript |
| window.addEventListener('message', function(event) { |
| if (event.data && event.data.type === 'flutter-scroll') { |
| window.scrollBy(event.data.deltaX, event.data.deltaY); |
| } |
| }); |
| ``` |
| |
| ## Demo |
| |
| - **Before Fix**: https://issue-156985-before.web.app |
| - **After Fix**: https://issue-156985-after.web.app |
| |
| ## Behavior After Fix |
| |
| 1. ✅ Scrolling inside Flutter iframe does NOT scroll parent page |
| 2. ✅ When Flutter content reaches boundary, parent page scrolls |
| 3. ✅ Nested scrollables work correctly - outer scrollable handles event before parent page |
| 4. ✅ Works with both same-origin and cross-origin iframes |
| |