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

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 https://github.com/flutter/flutter/issues/172561
diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart
index b45f3d9..5bcc457 100644
--- a/packages/flutter/lib/src/rendering/paragraph.dart
+++ b/packages/flutter/lib/src/rendering/paragraph.dart
@@ -41,8 +41,10 @@
 
 /// Signature for a function that determines the [_TextBoundaryRecord] at the given
 /// [TextPosition], for the given [String].
-typedef _TextBoundaryAtPositionInText =
-    _TextBoundaryRecord Function(TextPosition position, String text);
+typedef _TextBoundaryAtPositionInText = _TextBoundaryRecord Function(
+  TextPosition position,
+  String text,
+);
 
 const String _kEllipsis = '\u2026';
 
@@ -353,6 +355,7 @@
     List<RenderBox>? children,
     Color? selectionColor,
     SelectionRegistrar? registrar,
+    double devicePixelRatio = 1.0,
   }) : assert(text.debugAssertIsValid()),
        assert(maxLines == null || maxLines > 0),
        assert(
@@ -361,6 +364,7 @@
        ),
        _softWrap = softWrap,
        _overflow = overflow,
+       _devicePixelRatio = devicePixelRatio,
        _selectionColor = selectionColor,
        _textPainter = TextPainter(
          text: text,
@@ -665,6 +669,25 @@
     markNeedsLayout();
   }
 
+  /// The number of device pixels for each logical pixel.
+  ///
+  /// This is used by some renderers (like WebParagraph on the web) to
+  /// regenerate the text bitmap when the scale changes.
+  double get devicePixelRatio => _devicePixelRatio;
+  double _devicePixelRatio;
+
+  set devicePixelRatio(double value) {
+    if (_devicePixelRatio == value) {
+      return;
+    }
+    _devicePixelRatio = value;
+    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();
+    }
+  }
+
   /// An optional maximum number of lines for the text to span, wrapping if
   /// necessary. If the text exceeds the given number of lines, it will be
   /// truncated according to [overflow] and [softWrap].
@@ -1475,6 +1498,7 @@
     );
     properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
     properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
+    properties.add(DoubleProperty('devicePixelRatio', devicePixelRatio, defaultValue: 1.0));
   }
 }
 
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index a706eb8..8848b3b 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -24,6 +24,8 @@
 import 'framework.dart';
 import 'indexed_stack.dart';
 import 'localizations.dart';
+import 'media_query.dart';
+import 'view.dart';
 import 'widget_span.dart';
 
 export 'package:flutter/animation.dart';
@@ -6614,6 +6616,9 @@
   /// widgets.
   final Color? selectionColor;
 
+  double _getDevicePixelRatio(BuildContext context) =>
+      MediaQuery.maybeDevicePixelRatioOf(context) ?? View.maybeOf(context)?.devicePixelRatio ?? 1.0;
+
   @override
   RenderParagraph createRenderObject(BuildContext context) {
     assert(textDirection != null || debugCheckHasDirectionality(context));
@@ -6631,6 +6636,7 @@
       locale: locale ?? Localizations.maybeLocaleOf(context),
       registrar: selectionRegistrar,
       selectionColor: selectionColor,
+      devicePixelRatio: _getDevicePixelRatio(context),
     );
   }
 
@@ -6650,7 +6656,8 @@
       ..textHeightBehavior = textHeightBehavior
       ..locale = locale ?? Localizations.maybeLocaleOf(context)
       ..registrar = selectionRegistrar
-      ..selectionColor = selectionColor;
+      ..selectionColor = selectionColor
+      ..devicePixelRatio = _getDevicePixelRatio(context);
   }
 
   @override
diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart
index 66dfff6..2f2bf9e 100644
--- a/packages/flutter/test/rendering/paragraph_test.dart
+++ b/packages/flutter/test/rendering/paragraph_test.dart
@@ -4,6 +4,7 @@
 
 import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, ClipOp, Paragraph, TextBox;
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter/widgets.dart';
@@ -398,6 +399,57 @@
     expect(getRectForA(), const Rect.fromLTWH(90, 0, 10, 10));
   });
 
+  test('RenderParagraph devicePixelRatio control test', () {
+    final paragraph = RenderParagraph(
+      const TextSpan(text: 'Hello'),
+      textDirection: TextDirection.ltr,
+    );
+    layout(paragraph);
+    pumpFrame(phase: EnginePhase.paint);
+
+    expect(paragraph.debugNeedsLayout, isFalse);
+    expect(paragraph.debugNeedsPaint, isFalse);
+    expect(paragraph.devicePixelRatio, 1.0);
+
+    paragraph.devicePixelRatio = 2.0;
+    expect(paragraph.devicePixelRatio, 2.0);
+    expect(paragraph.debugNeedsLayout, isFalse);
+    // On the web, changing devicePixelRatio triggers a repaint.
+    expect(paragraph.debugNeedsPaint, kIsWeb);
+
+    if (kIsWeb) {
+      pumpFrame(phase: EnginePhase.paint);
+      expect(paragraph.debugNeedsPaint, isFalse);
+      paragraph.devicePixelRatio = 2.0;
+      expect(paragraph.debugNeedsPaint, isFalse);
+    }
+  });
+
+  test('RenderParagraph devicePixelRatio constructor test', () {
+    final paragraph = RenderParagraph(
+      const TextSpan(text: 'Hello'),
+      textDirection: TextDirection.ltr,
+      devicePixelRatio: 2.0,
+    );
+    expect(paragraph.devicePixelRatio, 2.0);
+  });
+
+  test('RenderParagraph.debugFillProperties', () {
+    final paragraph = RenderParagraph(
+      const TextSpan(text: 'Hello'),
+      textDirection: TextDirection.ltr,
+      devicePixelRatio: 2.5,
+    );
+    final builder = DiagnosticPropertiesBuilder();
+    paragraph.debugFillProperties(builder);
+
+    final List<DiagnosticsNode> nodes = builder.properties;
+    expect(
+      nodes.any((DiagnosticsNode node) => node.name == 'devicePixelRatio' && node.value == 2.5),
+      isTrue,
+    );
+  });
+
   group('didExceedMaxLines', () {
     RenderParagraph createRenderParagraph({
       int? maxLines,
diff --git a/packages/flutter/test/widgets/rich_text_test.dart b/packages/flutter/test/widgets/rich_text_test.dart
index d61c870..a1cdd4e 100644
--- a/packages/flutter/test/widgets/rich_text_test.dart
+++ b/packages/flutter/test/widgets/rich_text_test.dart
@@ -234,4 +234,75 @@
       ]),
     );
   });
+
+  testWidgets('RichText propagates devicePixelRatio', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      MediaQuery(
+        data: const MediaQueryData(devicePixelRatio: 3.0),
+        child: Directionality(
+          textDirection: TextDirection.ltr,
+          child: RichText(text: const TextSpan(text: 'Hello')),
+        ),
+      ),
+    );
+
+    RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+    expect(paragraph.devicePixelRatio, 3.0);
+
+    await tester.pumpWidget(
+      MediaQuery(
+        data: const MediaQueryData(devicePixelRatio: 4.0),
+        child: Directionality(
+          textDirection: TextDirection.ltr,
+          child: RichText(text: const TextSpan(text: 'Hello')),
+        ),
+      ),
+    );
+
+    paragraph = tester.renderObject(find.byType(RichText));
+    expect(paragraph.devicePixelRatio, 4.0);
+  });
+
+  testWidgets('RichText propagates devicePixelRatio from View', (WidgetTester tester) async {
+    tester.view.devicePixelRatio = 3.0;
+    addTearDown(tester.view.resetDevicePixelRatio);
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: RichText(text: const TextSpan(text: 'Hello')),
+      ),
+    );
+
+    RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+    expect(paragraph.devicePixelRatio, 3.0);
+
+    tester.view.devicePixelRatio = 4.0;
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: RichText(text: const TextSpan(text: 'Hello')),
+      ),
+    );
+
+    paragraph = tester.renderObject(find.byType(RichText));
+    expect(paragraph.devicePixelRatio, 4.0);
+  });
+
+  testWidgets('RichText defaults to 1.0 devicePixelRatio when no View or MediaQuery is present', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      RawView(
+        view: tester.view,
+        child: LookupBoundary(
+          child: Directionality(
+            textDirection: TextDirection.ltr,
+            child: RichText(text: const TextSpan(text: 'Hello')),
+          ),
+        ),
+      ),
+      wrapWithView: false,
+    );
+
+    final RenderParagraph paragraph = tester.renderObject(find.byType(RichText));
+    expect(paragraph.devicePixelRatio, 1.0);
+  });
 }
diff --git a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
index 21e477f..a731932 100644
--- a/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
+++ b/packages/flutter/test/widgets/sliver_fill_viewport_test.dart
@@ -129,6 +129,7 @@
         '   │     │ softWrap: wrapping at box width\n'
         '   │     │ overflow: clip\n'
         '   │     │ maxLines: unlimited\n'
+        '   │     │ devicePixelRatio: 3.0\n'
         '   │     ╘═╦══ text ═══\n'
         '   │       ║ TextSpan:\n'
         '   │       ║   <all styles inherited>\n'
@@ -160,6 +161,7 @@
         '         │ softWrap: wrapping at box width\n'
         '         │ overflow: clip\n'
         '         │ maxLines: unlimited\n'
+        '         │ devicePixelRatio: 3.0\n'
         '         ╘═╦══ text ═══\n'
         '           ║ TextSpan:\n'
         '           ║   <all styles inherited>\n'
diff --git a/packages/flutter/test/widgets/table_test.dart b/packages/flutter/test/widgets/table_test.dart
index 3f5fc10..da74090 100644
--- a/packages/flutter/test/widgets/table_test.dart
+++ b/packages/flutter/test/widgets/table_test.dart
@@ -729,23 +729,23 @@
       equalsIgnoringHashCodes(
         'Table-[GlobalKey#00000](dependencies: [Directionality, MediaQuery], renderObject: RenderTable#00000)\n'
         '├Text("A", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "A", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "A", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("B", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "B", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "B", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("C", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "C", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "C", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("D", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "D", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "D", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("EEE", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "EEE", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "EEE", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("F", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "F", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "F", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("G", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "G", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "G", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '├Text("H", dependencies: [MediaQuery])\n'
-        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "H", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
+        '│└RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "H", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n'
         '└Text("III", dependencies: [MediaQuery])\n'
-        ' └RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "III", dependencies: [Directionality], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n',
+        ' └RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "III", dependencies: [Directionality, MediaQuery], renderObject: RenderParagraph#00000 relayoutBoundary=up1)\n',
       ),
     );
   });