Skip to content

[web] Fix scroll event bubbling in iframes#179703

Merged
flutter-zl merged 11 commits into
flutter:masterfrom
flutter-zl:issue-156985
Feb 12, 2026
Merged

[web] Fix scroll event bubbling in iframes#179703
flutter-zl merged 11 commits into
flutter:masterfrom
flutter-zl:issue-156985

Conversation

@flutter-zl

@flutter-zl flutter-zl commented Dec 10, 2025

Copy link
Copy Markdown
Contributor

Fixes scroll event bubbling when Flutter web is embedded in an iframe(#156985).

Problem

When a full-page Flutter web app is embedded in an iframe, scroll chaining to
the host page does not work. The engine unconditionally calls preventDefault
on wheel events in engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart,
which cancels the browser's native bubbling.

Fix

packages/flutter/lib/src/widgets/scrollable.dart now reports back via
respond(allowPlatformDefault: false) when a scrollable actually handles a
wheel event. For a full-page app inside an iframe, the engine skips
preventDefault when every scrollable is at its boundary, letting the browser
bubble the wheel out to the host page natively. Other embeddings keep the
original behavior.

Demo

Before: https://issue-156985-before.web.app/
After: https://issue-156985-after.web.app/

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. engine flutter/engine related. See also e: labels. f: scrolling Viewports, list views, slivers, etc. platform-web Web applications specifically labels Dec 10, 2025
@flutter-zl flutter-zl requested a review from mdebbar December 15, 2025 17:56
@flutter-zl flutter-zl marked this pull request as ready for review December 15, 2025 17:56
final bool shouldScrollParent =
_lastWheelEventAllowedDefault && !_lastWheelEventHandledByWidget;
if (shouldScrollParent) {
scrollParentWindow(wheelEvent.deltaX, wheelEvent.deltaY);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually do anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this executes when all Flutter scrollables are at their boundary. For example:

  1. User has a ListView scrolled to the bottom
  2. User scrolls down again
  3. ListView calls respond(allowPlatformDefault: true) since it's at boundary
  4. No widget calls respond(allowPlatformDefault: false)
  5. shouldScrollParent becomes true
  6. scrollParentWindow() is called to scroll the parent page

This is the intended behavior, propagate scroll to parent only when Flutter can't handle it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that this line is executed under certain conditions. But I'm trying to figure out if it has any effect. As far as I can tell, it sends a custom flutter-scroll event, but what does that event do or who is handling it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! In the previous implementation, scrollParentWindow() used postMessage to send a flutter-scroll event, which required the host page to add a message listener.
I've updated the implementation in the latest commit to use direct parent.scrollBy() instead. This works automatically for same-origin iframes without any host page code needed.
For cross-origin iframes, the browser's security restrictions prevent direct parent access, so it silently fails. This is a browser limitation with no workaround. Cross-origin iframes cannot scroll their parent page without explicit host page cooperation.

@mdebbar mdebbar Jan 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my other comment, this code is based on the assumption that Flutter apps inside iframes are always full-page.

Take the following example:

+------------------ Parent window -------------------+
|                                                    |
|                                                    |
|                                                    |
|  +-------- iframe (non-flutter host app) --------+ |
|  |                                               | |
|  |                                               | |
|  |                                               | |
|  |          +--- Flutter embedded view ----+     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          |                              |     | |
|  |          +------------------------------+     | |
|  +-----------------------------------------------+ |
+----------------------------------------------------+

If the user is scrolling the flutter view and there's no more content to scroll, what happens?

Expectation

The non-flutter host app in the iframe should scroll.

Actual

If I'm understanding the PR correctly, the parent window will be scrolled (if it has the same origin).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I tested with https://flutter-demo-07-before.web.app/parent.html, it did prove you are right. I updated the logic ti check isFullPage. After the change: https://flutter-demo-07-after.web.app/parent.html.

Please let me know if I miss anything.

Comment on lines 849 to 852
if (isInIframe) {
// In an iframe: always prevent default to block native scroll chaining.
// This prevents both the iframe and parent from scrolling simultaneously.
event.preventDefault();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So when we are in an iframe now, we always prevent default.

What's the reason we aren't always preventing default outside iframes? And why does that reason not apply to apps running inside iframes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside iframes, we conditionally skip preventDefault() when a scrollable is at its boundary (allowPlatformDefault: true). This lets the browser handle the scroll.

Inside iframes, this reason doesn't apply because:
1.The Flutter app fills the entire iframe. there's no "page" within the iframe for the browser to scroll.
2."Platform default" has a different effect: outside an iframe it means "scroll this page," but inside an iframe it means "chain the scroll to the parent page".
3.Scroll chaining is the bug: allowing platform default would cause both iframe and parent to scroll simultaneously (#156985)

So we always block native behavior in iframes, then explicitly handle parent scrolling via postMessage when all Flutter scrollables are at their boundary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I now understand the thinking behind this, thanks for explaining!

Inside iframes, this reason doesn't apply because:
1.The Flutter app fills the entire iframe. there's no "page" within the iframe for the browser to scroll.

This assumption is not valid. It's possible for an iframe to contain a flutter app that doesn't fill the entire page.

Based on what you said, I wonder if we should change the isInIframe condition. Instead of checking for an iframe, let's check if the flutter app is filling the entire page. And if it's filling the entire page, then always prevent default. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My assumption about Flutter filling the entire iframe was too strong.
However, I still think isInIframe is the correct condition.
The core problem is scroll chaining across the iframe boundary, not whether Flutter fills the page. In both cases, the browser's scroll chaining causes the same problem: when the user scrolls over the Flutter content, both the iframe content and parent page scroll together. The size of the Flutter app doesn't change this behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me try to break it down so that I understand exactly what's changing and what issues are being fixed (and whether we are introducing any new issues).

What we used to do:

  • Conditionally preventDefault()
  • Can't unconditionally preventDefault() because some apps [1] don't work correctly if we unconditionally preventDefault().

What this PR is doing:

  • Inside iframes: unconditionally preventDefault() (new behavior)
  • Outside iframes: conditionally preventDefault() (existing behavior)

With the new behavior in this PR, my concern is that some apps [1] will have a broken scrolling experience if they are placed inside an iframe.

I'm thinking we could minimize this breakage by changing the condition to check for full-page apps instead of checking for inside vs outside iframes.

@flutter-zl flutter-zl Jan 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed breakdown.
The wheel event listener is attached to _viewTarget (Flutter's root element), not the document or iframe body.
So for the scenario you described (Flutter + HTML scrollable content side-by-side in an iframe), the HTML elements won't trigger Flutter's wheel handler because the listener is scoped to _viewTarget. Please check https://issue-156985-after.web.app/hybrid_demo.html. Both (left side and right side)scroll independently, proving that HTML elements alongside Flutter are not affected by the fix.
Let me know if you have a specific scenario in mind that this doesn't cover.

main demo showing the original issue fix: https://issue-156985-after.web.app/

@flutter-zl flutter-zl requested a review from mdebbar January 12, 2026 22:18
Comment on lines +682 to +701
bool _isFullPageApp() {
if (_cachedIsFullPageApp != null) {
return _cachedIsFullPageApp!;
}

try {
final DomElement? body = domDocument.body;
if (body != null) {
final String? embedding = body.getAttribute('flt-embedding');
_cachedIsFullPageApp = embedding == 'full-page';
return _cachedIsFullPageApp!;
}
} catch (e) {
// If we can't determine embedding type, default to full-page.
// This is the conservative choice: it applies the iframe scroll fix,
// which is the expected behavior for most Flutter web apps.
}
_cachedIsFullPageApp = true;
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to check the DOM for this. We already have this information. You can do something like:

Suggested change
bool _isFullPageApp() {
if (_cachedIsFullPageApp != null) {
return _cachedIsFullPageApp!;
}
try {
final DomElement? body = domDocument.body;
if (body != null) {
final String? embedding = body.getAttribute('flt-embedding');
_cachedIsFullPageApp = embedding == 'full-page';
return _cachedIsFullPageApp!;
}
} catch (e) {
// If we can't determine embedding type, default to full-page.
// This is the conservative choice: it applies the iframe scroll fix,
// which is the expected behavior for most Flutter web apps.
}
_cachedIsFullPageApp = true;
return true;
}
bool get _isFullPageApp => _view.embeddingStrategy is FullPageEmbeddingStrategy;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I updated the logic.

} else {
// Original behavior for:
// 1. Apps NOT in an iframe (normal page)
// 2. Custom element apps in an iframe (Flutter as a component)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 2. Custom element apps in an iframe (Flutter as a component)
// 2. Mulit-view mode inside an iframe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Updated.

Comment on lines +895 to +913
if (isInIframe && isFullPage) {
// Full-page app in an iframe: always preventDefault to block native
// scroll chaining. Without this, both the iframe content AND the parent
// window would scroll simultaneously when the user scrolls.
event.preventDefault();

// Scroll parent window only when Flutter scrollables can't use the event:
// - _lastWheelEventAllowedDefault: at least one scrollable is at boundary
// - !_lastWheelEventHandledByWidget: no scrollable consumed the event
//
// For nested scrollables, inner widgets set _lastWheelEventHandledByWidget
// to true, preventing parent window scroll even if outer is at boundary.
//
// Fixes GitHub issue #156985
final bool shouldScrollParent =
_lastWheelEventAllowedDefault && !_lastWheelEventHandledByWidget;
if (shouldScrollParent) {
scrollParentWindow(wheelEvent.deltaX, wheelEvent.deltaY);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last thought here to try to avoid the scrollParentWindow:

Instead of doing event.preventDefault() and then manually scrolling the parent, we should try something like this:

if (isInIframe && isFullPage) {
  final bool shouldScrollParent =
      _lastWheelEventAllowedDefault && !_lastWheelEventHandledByWidget;
  if (!shouldScrollParent) {
    event.preventDefault();
  }
} else {
  // ...
}

I'm not sure if it would work or not, but if it works let's do it!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I updated the logic and deploy to https://issue-156985-after.web.app/. It works. The code is cleaner.

@flutter-zl flutter-zl requested a review from mdebbar February 11, 2026 18:08

@mdebbar mdebbar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@flutter-zl flutter-zl added this pull request to the merge queue Feb 12, 2026
Merged via the queue into flutter:master with commit 7bafe12 Feb 12, 2026
178 checks passed
@flutter-zl flutter-zl deleted the issue-156985 branch February 12, 2026 22:01
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 13, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 13, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 13, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 14, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 14, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 14, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 14, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 15, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 15, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 15, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 16, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 16, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 16, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 17, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 17, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 17, 2026
rickhohler pushed a commit to rickhohler/flutter that referenced this pull request Feb 19, 2026
Fixes scroll event bubbling when Flutter web is embedded in an
iframe(flutter#156985).

When a scrollable handles a wheel event, it now calls
respond(allowPlatformDefault: false) to signal the engine.

In iframe mode, the engine always calls preventDefault() to block native
scroll chaining, then uses postMessage to explicitly scroll the parent
page only when all scrollables are at boundary

Before change:
https://issue-156985-before.web.app/

After change:
https://issue-156985-after.web.app/
mboetger pushed a commit to mboetger/flutter that referenced this pull request Mar 26, 2026
Fixes scroll event bubbling when Flutter web is embedded in an
iframe(flutter#156985).

When a scrollable handles a wheel event, it now calls
respond(allowPlatformDefault: false) to signal the engine.

In iframe mode, the engine always calls preventDefault() to block native
scroll chaining, then uses postMessage to explicitly scroll the parent
page only when all scrollables are at boundary

Before change:
https://issue-156985-before.web.app/

After change:
https://issue-156985-after.web.app/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engine flutter/engine related. See also e: labels. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels. platform-web Web applications specifically

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants