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.
The issue stems from how browsers handle scroll event propagation:
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”)
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.
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”.
pointer_binding.dart)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.
New flag _lastWheelEventHandledByWidget: Tracks whether ANY widget explicitly handled the scroll event (responded with allowPlatformDefault: false).
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().
Iframe detection: Added _isEmbeddedInIframe() method to detect if Flutter is running inside an iframe by checking if window.parent !== window.
// 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); }
scrollable.dart)Added explicit respond(allowPlatformDefault: false) when a scrollable successfully handles a scroll event:
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); } }
dom.dart)Added scrollParentWindow() function to scroll the parent page via postMessage:
void scrollParentWindow(double deltaX, double deltaY) { final JSAny jsMessage = <String, dynamic>{ 'type': 'flutter-scroll', 'deltaX': deltaX, 'deltaY': deltaY, }.jsify()!; domWindow.parent._postMessage(jsMessage, '*'); }
| 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 |
The host page must listen for flutter-scroll messages to receive scroll propagation:
window.addEventListener('message', function(event) { if (event.data && event.data.type === 'flutter-scroll') { window.scrollBy(event.data.deltaX, event.data.deltaY); } });