Skip to content

[web] RenderParagraph needs paint after a DPR change#186968

Merged
auto-submit[bot] merged 9 commits into
flutter:masterfrom
mdebbar:webparagraph_dpr
Jun 16, 2026
Merged

[web] RenderParagraph needs paint after a DPR change#186968
auto-submit[bot] merged 9 commits into
flutter:masterfrom
mdebbar:webparagraph_dpr

Conversation

@mdebbar

@mdebbar mdebbar commented May 22, 2026

Copy link
Copy Markdown
Contributor

Currently, there are situation where a ui.Paragraph is not repainted, for example when it's inside a RepaintBoundary. When devicePixelRatio changes due to zooming in or out, and the paragraph isn't repainted, it results in WebParagraphs becoming blurry/choppy because they are painted as images.

The image needs to be regenerated for the new devicePixelRatio in order to become clear.

This PR adds the devicePixelRatio property to RenderParagraph and, on the web, a change in this property causes the paragraph to be repainted.

The cost of this PR on performance should be minimal because:

  1. It's limited to web.
  2. It only costs an extra paint when the dpr changes, which is not a common case (on the web, it happens when zooming in/out or when the window is moved to a different display).

Part of #172561

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label May 22, 2026
@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels May 22, 2026
@mdebbar mdebbar force-pushed the webparagraph_dpr branch from 8a9a44c to c5e02bb Compare May 27, 2026 18:24
@github-actions github-actions Bot removed the CICD Run CI/CD label May 27, 2026
@mdebbar mdebbar added the CICD Run CI/CD label May 27, 2026
@github-actions github-actions Bot added f: scrolling Viewports, list views, slivers, etc. and removed CICD Run CI/CD labels May 28, 2026
@mdebbar mdebbar added the CICD Run CI/CD label May 28, 2026
@github-actions github-actions Bot removed the CICD Run CI/CD label May 28, 2026
@mdebbar mdebbar added the CICD Run CI/CD label May 28, 2026
@mdebbar mdebbar marked this pull request as ready for review June 3, 2026 15:41

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request adds a devicePixelRatio property to RenderParagraph and updates RichText to propagate this value from MediaQuery. On the web, changes to devicePixelRatio trigger a repaint to prevent blurry text. Review feedback recommends adding View.maybeOf(context)?.devicePixelRatio as a fallback when MediaQuery is unavailable, and reverting an awkward test name modification in table_test.dart.

Comment thread packages/flutter/lib/src/widgets/basic.dart Outdated
Comment thread packages/flutter/lib/src/widgets/basic.dart Outdated
Comment thread packages/flutter/test/widgets/table_test.dart Outdated
if (kIsWeb) {
// The `WebParagraph` implementation renders the paragraph as an image. After a
// `devicePixelRatio` change, the image needs to be regenerated or it would look blurry.
markNeedsPaint();

@LongCatIsLooong LongCatIsLooong Jun 3, 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.

The device pixel ratio isn't passed into lower level. How does the painter get the correct dpr, as the application can override that in the tree?

EDIT: oh nvm. It looks like overriding devicePixelRatio doesn't do anything since the scaling transform is applied by RenderView.

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 the reason we're doing this is because webparagraph rasterizes the paragraph in the paint call?

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.

That's right. WebParagraph generates an image during the paint call. When the DPR changes, we need to regenerate the image using the new DPR, otherwise it will look blurry. The problem I was facing is that the framework sometimes skips repainting the paragraph even when DPR has changed. That made the text look bad.

The solution I came up with was to call markNeedsPaint() every time the DPR changes. If there's a better way to do it, I'm open to suggestions.

@harryterkelsen harryterkelsen 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.

What do we do if there is no MediaQuery ancestor?

@github-actions github-actions Bot removed the CICD Run CI/CD label Jun 4, 2026
@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Jun 4, 2026

@LongCatIsLooong LongCatIsLooong 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.

Hmm TBH not a big fan of triggering a repaint based on DPR (and the web-only markNeedsRepaint). What if I have a paragraph inside of a Transform widget (something like InteractiveViewer)? When I zoom in on the text would it appear blurry due to the pre-rasterization? Is it possible to do this based on the scale value of the CTM?

final Color? selectionColor;

double _getDevicePixelRatio(BuildContext context) =>
MediaQuery.maybeDevicePixelRatioOf(context) ?? View.maybeOf(context)?.devicePixelRatio ?? 1.0;

@LongCatIsLooong LongCatIsLooong Jun 5, 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.

nit: use a fallback value that's outside of valid DPR range? So we can distinguish "no View" vs "has View ancestor but its dpr is exactly 1.0". But I thought it was safe to assume there's always a View ancestor in an active widget subtree?

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.

Also View.maybeOf probably doesn't notify listeners on DPR change. It is only for monitoring the identity of the View: https://main-api.flutter.dev/flutter/widgets/View/of.html

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.

nit: use a fallback value that's outside of valid DPR range? So we can distinguish "no View" vs "has View ancestor but its dpr is exactly 1.0".

Why do we need to distinguish between "no View" and "View has dpr 1.0"? I don't think we care about this distinction.

But I thought it was safe to assume there's always a View ancestor in an active widget subtree?

I wasn't sure about this, so I opted to play it safe and assume that View could be null. If there's always a View, then shouldn't we remove the View.maybeOf method?

Also View.maybeOf probably doesn't notify listeners on DPR change.

That's right. There's no way to listen to View.dpr changes. That said, in regular apps a MediaQuery should be present and we do listen to DPR changes on it. That should cover the majority of cases. Using View.maybeOf(context).dpr is only a fallback that shouldn't happen often.

@mdebbar

mdebbar commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Hmm TBH not a big fan of triggering a repaint based on DPR (and the web-only markNeedsRepaint). What if I have a paragraph inside of a Transform widget (something like InteractiveViewer)? When I zoom in on the text would it appear blurry due to the pre-rasterization? Is it possible to do this based on the scale value of the CTM?

You're right, the paragraph will look blurry if it's scaled by a Transform or InteractiveViewer. There are two concerns I have with using the value of CTM:

  1. We can't listen to CTM changes and force a repaint. If I understand correctly, CTM is only available during the paint phase, so if the framework skips painting altogether, we won't detect a CTM change.
  2. During a scale animation, it may be prohibitively expensive to regenerate the image on every frame. So we would need to throttle it in some way.

Fixing the DPR case is more straightforward and more valuable because it affects all apps.

@mdebbar

mdebbar commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

What do we do if there is no MediaQuery ancestor?

At the time you asked this question, the answer would've been: We default to 1.0.

Now the answer is: We try to get the DPR from the nearest View ancestor, or default to 1.0.

@LongCatIsLooong

Copy link
Copy Markdown
Contributor
  1. We can't listen to CTM changes and force a repaint. If I understand correctly, CTM is only available during the paint phase, so if the framework skips painting altogether, we won't detect a CTM change.

I think this is doable using a composition callback (although there is going to be an one-frame delay if the text must be rasterized in a paint call, in which case the markNeedsPaint must happen in a post frame callback), example:

class _RenderCompositionCallback extends RenderProxyBox {
_RenderCompositionCallback(this.compositeCallback, this._enabled);
final CompositionCallback compositeCallback;
VoidCallback? _cancelCallback;
bool get enabled => _enabled;
bool _enabled = false;
set enabled(bool newValue) {
_enabled = newValue;
if (!newValue) {
_cancelCallback?.call();
_cancelCallback = null;
} else if (_cancelCallback == null) {
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, ui.Offset offset) {
if (enabled) {
_cancelCallback ??= context.addCompositionCallback(compositeCallback);
}
super.paint(context, offset);
}
}

  1. During a scale animation, it may be prohibitively expensive to regenerate the image on every frame. So we would need to throttle it in some way.

I would think the text needs to be re-rendered when the CTM changes on non-web platforms too? If that's the case is the problem that we usually do rasterization on the raster thread so it doesn't block UI events (does the web engine have a dedicated raster thread?) but for WebParagraph it has to be done on the dart UI thread because it happens in RenderObject.paint?

Oh according to gemini the glyphs will be cached (using the physical size as one of the cache keys).

Fixing the DPR case is more straightforward and more valuable because it affects all apps.

That makes sense but I think Transform + Text / EditableText are a very common widget combinations that this fix does not account for. On both iOS and Android there's this loupe / magnifier thingy that would show up over the text in a text field.

(On a different note is there a place that I can read more about how WebParagraph works?)

@harryterkelsen

Copy link
Copy Markdown
Contributor

Should we do something here that is similar to how the native engine handles its raster cache? When the native engine paints a picture in a scene, it may cache the rasterized picture so it doesn't need to re-rasterize it if it hasn't changed. But when it caches the rastered picture, it stores the current transform matrix. That way it can invalidate its cache if the transform matrix changes (with, IIRC, some checks to make sure that it didn't just do a translation). Could we do something similar with the cached WebParagraph rasters?

@LongCatIsLooong LongCatIsLooong 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.

In-app transforms will be addressed separately.

@mdebbar

mdebbar commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@LongCatIsLooong @harryterkelsen thank you both for bringing up good points! I created an issue for better CMT handling: #188025

@mdebbar mdebbar added the autosubmit Merge PR when tree becomes green via auto submit App label Jun 15, 2026
@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Jun 15, 2026
@auto-submit

auto-submit Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/186968, because The base commit of the PR is older than 7 days and can not be merged. Please merge the latest changes from the main into this branch and resubmit the PR.

@github-actions github-actions Bot removed the CICD Run CI/CD label Jun 15, 2026
@mdebbar mdebbar added CICD Run CI/CD autosubmit Merge PR when tree becomes green via auto submit App labels Jun 15, 2026
@auto-submit auto-submit Bot added this pull request to the merge queue Jun 16, 2026
Merged via the queue into flutter:master with commit 072b85c Jun 16, 2026
94 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Jun 16, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Jun 16, 2026
flutter/flutter@5827d5f...3a0420c

2026-06-16 Rusino@users.noreply.github.com Implement font fallback (flutter/flutter#187520)
2026-06-16 amhurtado@protonmail.com Add FlatBuffers Verifier checks to Impeller asset loading (flutter/flutter#187878)
2026-06-16 engine-flutter-autoroll@skia.org Roll Packages from aa964a3 to 8286d39 (1 revision) (flutter/flutter#188067)
2026-06-16 engine-flutter-autoroll@skia.org Roll Skia from 9c2b83788409 to d7196b0b4939 (1 revision) (flutter/flutter#188066)
2026-06-16 engine-flutter-autoroll@skia.org Roll Skia from ef17057bb776 to 9c2b83788409 (1 revision) (flutter/flutter#188061)
2026-06-16 engine-flutter-autoroll@skia.org Roll Skia from 500025456bb5 to ef17057bb776 (1 revision) (flutter/flutter#188058)
2026-06-16 engine-flutter-autoroll@skia.org Roll Skia from cb1035ff14bf to 500025456bb5 (5 revisions) (flutter/flutter#188057)
2026-06-16 engine-flutter-autoroll@skia.org Roll Fuchsia Linux SDK from TbB86Po_HDe1dvXvT... to VeLhhlDcod09NR4Hb... (flutter/flutter#188055)
2026-06-16 engine-flutter-autoroll@skia.org Roll Skia from 70acf6a5e7c9 to cb1035ff14bf (3 revisions) (flutter/flutter#188054)
2026-06-16 41930132+hellohuanlin@users.noreply.github.com [pv]skip non-tappable web view workaround on ios 26.4 (flutter/flutter#185424)
2026-06-16 mdebbar@google.com [web] RenderParagraph needs paint after a DPR change (flutter/flutter#186968)
2026-06-16 30870216+gaaclarke@users.noreply.github.com Adds gamma correction to windows text. (flutter/flutter#187871)
2026-06-15 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Add a platform view test to android_hardware_smoke_test (#187913)" (flutter/flutter#188051)
2026-06-15 awolff@google.com Add a platform view test to android_hardware_smoke_test (flutter/flutter#187913)
2026-06-15 codefu@google.com feat: linux_analyze in a workflow (flutter/flutter#187889)
2026-06-15 mdebbar@google.com [web] Changes to WebParagraph configuration (flutter/flutter#187188)
2026-06-15 matt.boetger@gmail.com Fail gracefully on Android AVD lock errors during startup (flutter/flutter#187200)
2026-06-15 bkonyi@google.com [flutter_tools] Fix flakiness in widget_preview_detection_test (flutter/flutter#187938)
2026-06-15 jason-simmons@users.noreply.github.com Exclude fuchsia-sdk/sdk/.build-id from the builder cache archive (flutter/flutter#187826)
2026-06-15 engine-flutter-autoroll@skia.org Roll Skia from c8d9f80f13e4 to 70acf6a5e7c9 (4 revisions) (flutter/flutter#188020)
2026-06-15 engine-flutter-autoroll@skia.org Roll Packages from b78ad83 to aa964a3 (7 revisions) (flutter/flutter#188021)

If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC bmparr@google.com,stuartmorgan@google.com on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
@mdebbar mdebbar deleted the webparagraph_dpr branch June 16, 2026 18:22
via-guy pushed a commit to via-guy/flutter that referenced this pull request Jun 26, 2026
Currently, there are situation where a `ui.Paragraph` is not repainted,
for example when it's inside a `RepaintBoundary`. When
`devicePixelRatio` changes due to zooming in or out, and the paragraph
isn't repainted, it results in `WebParagraph`s becoming blurry/choppy
because they are painted as images.

The image needs to be regenerated for the new `devicePixelRatio` in
order to become clear.

This PR adds the `devicePixelRatio` property to `RenderParagraph` and,
on the web, a change in this property causes the paragraph to be
repainted.

The cost of this PR on performance should be minimal because:
1. It's limited to web.
2. It only costs an extra paint when the dpr changes, which is not a
common case (on the web, it happens when zooming in/out or when the
window is moved to a different display).

Part of flutter#172561
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems CICD Run CI/CD f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants