[web] Changes to WebParagraph configuration (#187188)

Changes:

- Drop `experimental` from the path and config flags.
- **Reason**: Other experimental modes don't use the word "experimental"
(e.g. skwasm_heavy, wimp), and I don't want it to be a breaking change
to graduate out of the "experimental" phase.
<hr>

- Instead of having WebParagraph be a third CK variant, make it a global
`enableWebParagraph` flag.
- **Reason**: In the future, we will support Skwasm and Wimp, so it
doesn't make sense to limit the flag to CanvasKit.
<hr>

- Feature-detect Chrome's Text Cluster API and fallback to another CK
variant if it's not supported.
- **Reason**: To allow for a smoother adoption path. Without this, all
users will have to do their own feature detection to adopt WebParagraph.
<hr>

- Throw a clear message when Dart code is in WebParagraph mode but the
downloaded CK variant doesn't support WebParagraph.
- **Reason**: I was bit by this multiple times. It shouldn't happen
anymore, but if it does, I want the error message to be clear instead of
some random failure.

<hr>

Fixes https://github.com/flutter/flutter/issues/186215
Part of https://github.com/flutter/flutter/issues/172561

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
diff --git a/engine/src/flutter/ci/builders/linux_web_engine_test.json b/engine/src/flutter/ci/builders/linux_web_engine_test.json
index e818bc9..54f200d 100644
--- a/engine/src/flutter/ci/builders/linux_web_engine_test.json
+++ b/engine/src/flutter/ci/builders/linux_web_engine_test.json
@@ -3,7 +3,7 @@
   "_comment2": "See `generate_builder_json.dart` for the generator code",
   "builds": [
     {
-      "name": "web_tests/test_bundles/dart2js-canvaskit-experimental-webparagraph",
+      "name": "web_tests/test_bundles/dart2js-canvaskit-webparagraph",
       "drone_dimensions": [
         "device_type=none",
         "os=Linux"
@@ -11,11 +11,11 @@
       "generators": {
         "tasks": [
           {
-            "name": "compile bundle dart2js-canvaskit-experimental-webparagraph",
+            "name": "compile bundle dart2js-canvaskit-webparagraph",
             "parameters": [
               "test",
               "--compile",
-              "--bundle=dart2js-canvaskit-experimental-webparagraph"
+              "--bundle=dart2js-canvaskit-webparagraph"
             ],
             "scripts": [
               "flutter/lib/web_ui/dev/felt"
@@ -209,7 +209,7 @@
         "download_jdk": false
       },
       "dependencies": [
-        "web_tests/test_bundles/dart2js-canvaskit-experimental-webparagraph",
+        "web_tests/test_bundles/dart2js-canvaskit-webparagraph",
         "web_tests/test_bundles/dart2wasm-skwasm-ui",
         "web_tests/test_bundles/dart2wasm-skwasm-skwasm",
         "web_tests/test_bundles/dart2js-canvaskit-engine",
@@ -234,7 +234,7 @@
           "parameters": [
             "test",
             "--copy-artifacts",
-            "--suite=chrome-dart2js-experimental-webparagraph-ui",
+            "--suite=chrome-dart2js-webparagraph-ui",
             "--suite=chrome-dart2wasm-wimp-ui",
             "--suite=chrome-dart2wasm-wimp-skwasm",
             "--suite=chrome-dart2js-canvaskit-engine",
@@ -254,11 +254,11 @@
           "script": "flutter/lib/web_ui/dev/felt"
         },
         {
-          "name": "run suite chrome-dart2js-experimental-webparagraph-ui",
+          "name": "run suite chrome-dart2js-webparagraph-ui",
           "parameters": [
             "test",
             "--run",
-            "--suite=chrome-dart2js-experimental-webparagraph-ui"
+            "--suite=chrome-dart2js-webparagraph-ui"
           ],
           "script": "flutter/lib/web_ui/dev/felt"
         },
diff --git a/engine/src/flutter/lib/web_ui/dev/build.dart b/engine/src/flutter/lib/web_ui/dev/build.dart
index 6746c43..507e235 100644
--- a/engine/src/flutter/lib/web_ui/dev/build.dart
+++ b/engine/src/flutter/lib/web_ui/dev/build.dart
@@ -18,8 +18,7 @@
   'web_sdk': 'flutter/web_sdk',
   'canvaskit': 'flutter/third_party/canvaskit:canvaskit_group',
   'canvaskit_chromium': 'flutter/third_party/canvaskit:canvaskit_chromium_group',
-  'canvaskit_webparagraph':
-      'flutter/third_party/canvaskit:canvaskit_experimental_webparagraph_group',
+  'canvaskit_webparagraph': 'flutter/third_party/canvaskit:canvaskit_webparagraph_group',
   'skwasm': 'flutter/third_party/canvaskit:skwasm_group',
   'skwasm_st': 'flutter/third_party/canvaskit:skwasm_st_group',
   'skwasm_heavy': 'flutter/third_party/canvaskit:skwasm_heavy_group',
diff --git a/engine/src/flutter/lib/web_ui/dev/felt_config.dart b/engine/src/flutter/lib/web_ui/dev/felt_config.dart
index 7c7aa2b..e18fe18 100644
--- a/engine/src/flutter/lib/web_ui/dev/felt_config.dart
+++ b/engine/src/flutter/lib/web_ui/dev/felt_config.dart
@@ -32,7 +32,7 @@
   final List<CompileConfiguration> compileConfigs;
 }
 
-enum CanvasKitVariant { full, chromium, experimentalWebParagraph }
+enum CanvasKitVariant { full, chromium }
 
 enum BrowserName { chrome, edge, firefox, safari }
 
@@ -44,6 +44,7 @@
     this.variant,
     this.crossOriginIsolated,
     this.forceSingleThreadedSkwasm,
+    this.enableWebParagraph,
     this.enableWimp,
     this.wasmAllowList,
   );
@@ -54,33 +55,33 @@
   final CanvasKitVariant? variant;
   final bool crossOriginIsolated;
   final bool forceSingleThreadedSkwasm;
+  final bool enableWebParagraph;
   final bool enableWimp;
   final Map<String, bool> wasmAllowList;
 }
 
 class ArtifactDependencies {
   ArtifactDependencies({
-    required this.canvasKitExperimentalWebParagraph,
+    required this.canvasKitWebParagraph,
     required this.canvasKit,
     required this.canvasKitChromium,
     required this.skwasm,
   });
 
   ArtifactDependencies.none()
-    : canvasKitExperimentalWebParagraph = false,
+    : canvasKitWebParagraph = false,
       canvasKit = false,
       canvasKitChromium = false,
       skwasm = false;
 
-  final bool canvasKitExperimentalWebParagraph;
+  final bool canvasKitWebParagraph;
   final bool canvasKit;
   final bool canvasKitChromium;
   final bool skwasm;
 
   ArtifactDependencies operator |(ArtifactDependencies other) {
     return ArtifactDependencies(
-      canvasKitExperimentalWebParagraph:
-          canvasKitExperimentalWebParagraph || other.canvasKitExperimentalWebParagraph,
+      canvasKitWebParagraph: canvasKitWebParagraph || other.canvasKitWebParagraph,
       canvasKit: canvasKit || other.canvasKit,
       canvasKitChromium: canvasKitChromium || other.canvasKitChromium,
       skwasm: skwasm || other.skwasm,
@@ -89,8 +90,7 @@
 
   ArtifactDependencies operator &(ArtifactDependencies other) {
     return ArtifactDependencies(
-      canvasKitExperimentalWebParagraph:
-          canvasKitExperimentalWebParagraph && other.canvasKitExperimentalWebParagraph,
+      canvasKitWebParagraph: canvasKitWebParagraph && other.canvasKitWebParagraph,
       canvasKit: canvasKit && other.canvasKit,
       canvasKitChromium: canvasKitChromium && other.canvasKitChromium,
       skwasm: skwasm && other.skwasm,
@@ -194,6 +194,7 @@
       final bool crossOriginIsolated = runConfigYaml['cross-origin-isolated'] as bool? ?? false;
       final bool forceSingleThreadedSkwasm =
           runConfigYaml['force-single-threaded-skwasm'] as bool? ?? false;
+      final bool enableWebParagraph = runConfigYaml['enable-webparagraph'] as bool? ?? false;
       final bool enableWimp = runConfigYaml['enable-wimp'] as bool? ?? false;
       final YamlMap wasmAllowList = (runConfigYaml['wasm-allow-list'] as YamlMap?) ?? YamlMap();
       final runConfig = RunConfiguration(
@@ -203,6 +204,7 @@
         variant,
         crossOriginIsolated,
         forceSingleThreadedSkwasm,
+        enableWebParagraph,
         enableWimp,
         wasmAllowList.cast<String, bool>(),
       );
@@ -231,7 +233,7 @@
           'Run config not found with name: `$runConfigName` (referenced by test suite: `$name`)',
         );
       }
-      var canvasKitExperimentalWebParagraph = false;
+      var canvasKitWebParagraph = false;
       var canvasKit = false;
       var canvasKitChromium = false;
       var skwasm = false;
@@ -239,11 +241,11 @@
       if (depsNode != null) {
         for (final dynamic dep in depsNode as YamlList) {
           switch (dep as String) {
-            case 'canvaskit_experimental_webparagraph':
-              if (canvasKitExperimentalWebParagraph) {
+            case 'canvaskit_webparagraph':
+              if (canvasKitWebParagraph) {
                 throw AssertionError('Artifact dep $dep listed twice in suite $name.');
               }
-              canvasKitExperimentalWebParagraph = true;
+              canvasKitWebParagraph = true;
             case 'canvaskit':
               if (canvasKit) {
                 throw AssertionError('Artifact dep $dep listed twice in suite $name.');
@@ -265,7 +267,7 @@
         }
       }
       final artifactDeps = ArtifactDependencies(
-        canvasKitExperimentalWebParagraph: canvasKitExperimentalWebParagraph,
+        canvasKitWebParagraph: canvasKitWebParagraph,
         canvasKit: canvasKit,
         canvasKitChromium: canvasKitChromium,
         skwasm: skwasm,
diff --git a/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart b/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart
index e95e0b3..ed1b201 100644
--- a/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart
+++ b/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart
@@ -74,7 +74,7 @@
   @override
   Future<void> run() async {
     final String flutterJsSourceDirectory;
-    final String canvaskitExperimentalWebParagraphSourceDirectory;
+    final String canvaskitWebParagraphSourceDirectory;
     final String canvaskitSourceDirectory;
     final String canvaskitChromiumSourceDirectory;
     final String skwasmSourceDirectory;
@@ -83,9 +83,9 @@
       case LocalArtifactSource(:final RuntimeMode mode):
         final String buildDirectory = getBuildDirectoryForRuntimeMode(mode).path;
         flutterJsSourceDirectory = pathlib.join(buildDirectory, 'flutter_web_sdk', 'flutter_js');
-        canvaskitExperimentalWebParagraphSourceDirectory = pathlib.join(
+        canvaskitWebParagraphSourceDirectory = pathlib.join(
           buildDirectory,
-          'canvaskit_experimental_webparagraph',
+          'canvaskit_webparagraph',
         );
         canvaskitSourceDirectory = pathlib.join(buildDirectory, 'canvaskit');
         canvaskitChromiumSourceDirectory = pathlib.join(buildDirectory, 'canvaskit_chromium');
@@ -95,10 +95,10 @@
       case GcsArtifactSource(:final LuciRealm realm):
         final String artifactsDirectory = (await _downloadArtifacts(realm)).path;
         flutterJsSourceDirectory = pathlib.join(artifactsDirectory, 'flutter_js');
-        canvaskitExperimentalWebParagraphSourceDirectory = pathlib.join(
+        canvaskitWebParagraphSourceDirectory = pathlib.join(
           artifactsDirectory,
           'canvaskit',
-          'experimental_webparagraph',
+          'webparagraph',
         );
         canvaskitSourceDirectory = pathlib.join(artifactsDirectory, 'canvaskit');
         canvaskitChromiumSourceDirectory = pathlib.join(
@@ -116,12 +116,12 @@
     await copySkiaTestImages();
     await copyFlutterJsFiles(flutterJsSourceDirectory);
     final copied = <String>[];
-    if (artifactDeps.canvasKitExperimentalWebParagraph) {
-      copied.add('CanvasKit (Experimental Web Paragraph)');
+    if (artifactDeps.canvasKitWebParagraph) {
+      copied.add('CanvasKit (Web Paragraph)');
       await copyWasmLibrary(
         'canvaskit',
-        canvaskitExperimentalWebParagraphSourceDirectory,
-        'canvaskit/experimental_webparagraph',
+        canvaskitWebParagraphSourceDirectory,
+        'canvaskit/webparagraph',
       );
     }
     if (artifactDeps.canvasKit) {
diff --git a/engine/src/flutter/lib/web_ui/dev/steps/run_suite_step.dart b/engine/src/flutter/lib/web_ui/dev/steps/run_suite_step.dart
index 2657f60..c0b304d 100644
--- a/engine/src/flutter/lib/web_ui/dev/steps/run_suite_step.dart
+++ b/engine/src/flutter/lib/web_ui/dev/steps/run_suite_step.dart
@@ -201,12 +201,14 @@
         rendererName = singleThreaded ? 'skwasm_st' : 'skwasm';
       }
     }
+    final bool isWebParagraph = suite.runConfig.enableWebParagraph;
 
     final dimensions = <String, String>{
       'Browser': suite.runConfig.browser.name,
       if (isWasm) 'Wasm': 'true',
       'Renderer': rendererName,
       'CanvasKitVariant': ?variant?.name,
+      if (isWebParagraph) 'WebParagraph': 'true',
     };
     final skiaClient = SkiaGoldClient(workDirectory, dimensions: dimensions);
 
diff --git a/engine/src/flutter/lib/web_ui/dev/test_platform.dart b/engine/src/flutter/lib/web_ui/dev/test_platform.dart
index d66c374..f2bf537 100644
--- a/engine/src/flutter/lib/web_ui/dev/test_platform.dart
+++ b/engine/src/flutter/lib/web_ui/dev/test_platform.dart
@@ -475,7 +475,6 @@
     return switch (suite.runConfig.variant) {
       CanvasKitVariant.full => 'full',
       CanvasKitVariant.chromium => 'chromium',
-      CanvasKitVariant.experimentalWebParagraph => 'experimentalWebParagraph',
       null => 'auto',
     };
   }
@@ -534,6 +533,7 @@
       canvasKitBaseUrl: "/canvaskit",
       forceSingleThreadedSkwasm: ${suite.runConfig.forceSingleThreadedSkwasm},
       wasmAllowList: ${jsonEncode(suite.runConfig.wasmAllowList)},
+      preferWebParagraph: ${suite.runConfig.enableWebParagraph},
       enableWimp: ${suite.runConfig.enableWimp},
     },
   });
diff --git a/engine/src/flutter/lib/web_ui/dev/test_runner.dart b/engine/src/flutter/lib/web_ui/dev/test_runner.dart
index ec6e3cb..3351a28 100644
--- a/engine/src/flutter/lib/web_ui/dev/test_runner.dart
+++ b/engine/src/flutter/lib/web_ui/dev/test_runner.dart
@@ -351,8 +351,8 @@
         print('  ${bundle.name.ansiMagenta}');
       }
       print('Artifacts:');
-      if (artifacts.canvasKitExperimentalWebParagraph) {
-        print('  canvaskit_experimental_webparagraph'.ansiYellow);
+      if (artifacts.canvasKitWebParagraph) {
+        print('  canvaskit_webparagraph'.ansiYellow);
       }
       if (artifacts.canvasKit) {
         print('  canvaskit'.ansiYellow);
diff --git a/engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js b/engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js
index 8a3dd34..24d5e3d 100644
--- a/engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js
+++ b/engine/src/flutter/lib/web_ui/flutter_js/src/browser_environment.js
@@ -49,6 +49,10 @@
     (typeof Intl.Segmenter !== "undefined");
 }
 
+const hasTextCluster = () => {
+  return (typeof window.TextCluster !== "undefined");
+}
+
 const supportsWasmGC = () => {
   // This attempts to instantiate a wasm module that only will validate if the
   // final WasmGC spec is implemented in the browser.
@@ -83,6 +87,7 @@
   browserEngine: browserEngine,
   hasImageCodecs: hasImageCodecs(),
   hasChromiumBreakIterators: hasChromiumBreakIterators(),
+  hasTextCluster: hasTextCluster(),
   supportsWasmGC: supportsWasmGC(),
   crossOriginIsolated: window.crossOriginIsolated,
   webGLVersion: detectWebGLVersion(),
diff --git a/engine/src/flutter/lib/web_ui/flutter_js/src/canvaskit_loader.js b/engine/src/flutter/lib/web_ui/flutter_js/src/canvaskit_loader.js
index 9c52318..cbb1317 100644
--- a/engine/src/flutter/lib/web_ui/flutter_js/src/canvaskit_loader.js
+++ b/engine/src/flutter/lib/web_ui/flutter_js/src/canvaskit_loader.js
@@ -16,9 +16,10 @@
       throw "Chromium CanvasKit variant specifically requested, but unsupported in this browser";
     }
     const useChromiumCanvasKit = supportsChromiumCanvasKit && (config.canvasKitVariant !== "full");
+    const useWebParagraphCanvasKit = useChromiumCanvasKit && config.preferWebParagraph && browserEnvironment.hasTextCluster;
     let baseUrl = canvasKitBaseUrl;
-    if (config.canvasKitVariant == "experimentalWebParagraph") {
-      baseUrl = resolveUrlWithSegments(baseUrl, "experimental_webparagraph");
+    if (useWebParagraphCanvasKit) {
+      baseUrl = resolveUrlWithSegments(baseUrl, "webparagraph");
     } else if (useChromiumCanvasKit) {
       baseUrl = resolveUrlWithSegments(baseUrl, "chromium");
     }
@@ -27,8 +28,8 @@
       canvasKitUrl = deps.flutterTT.policy.createScriptURL(canvasKitUrl);
     }
     let filename = "canvaskit.wasm";
-    if (config.canvasKitVariant == "experimentalWebParagraph") {
-      filename = "experimental_webparagraph/canvaskit.wasm";
+    if (useWebParagraphCanvasKit) {
+      filename = "webparagraph/canvaskit.wasm";
     } else if (useChromiumCanvasKit) {
       filename = "chromium/canvaskit.wasm";
     }
diff --git a/engine/src/flutter/lib/web_ui/flutter_js/src/types.d.ts b/engine/src/flutter/lib/web_ui/flutter_js/src/types.d.ts
index 0f7a3be..f69d6c8 100644
--- a/engine/src/flutter/lib/web_ui/flutter_js/src/types.d.ts
+++ b/engine/src/flutter/lib/web_ui/flutter_js/src/types.d.ts
@@ -59,6 +59,7 @@
   canvasKitBaseUrl?: string;
   canvasKitVariant?: CanvasKitVariant;
   renderer?: WebRenderer;
+  preferWebParagraph?: boolean;
   enableWimp?: boolean;
   hostElement?: HTMLElement;
   fontFallbackBaseUrl?: string;
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/browser_detection.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_detection.dart
index 56fa893..efdd374 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/browser_detection.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_detection.dart
@@ -140,6 +140,10 @@
 bool get browserSupportsCanvaskitChromium =>
     domIntl.v8BreakIterator != null && domIntl.Segmenter != null && browserSupportsImageDecoder;
 
+/// Whether the current browser supports the WebParagraph implementation.
+bool get browserSupportsWebParagraph =>
+    browserSupportsCanvaskitChromium && browserSupportsTextCluster;
+
 /// Whether the current browser is Safari 17.4 or newer.
 ///
 /// Safari 17.4 introduced support for aria-description.
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
index 8fe11ed..2ff0e23 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart
@@ -95,9 +95,14 @@
     Uint16List? indices,
   ) => _MakeVertices(mode, positions.toJS, textureCoordinates?.toJS, colors?.toJS, indices?.toJS);
 
-  external BidiNamespace get Bidi;
-
-  external CodeUnitsNamespace get CodeUnits;
+  @JS('Bidi')
+  external BidiNamespace? get _Bidi;
+  BidiNamespace get Bidi {
+    if (_Bidi == null) {
+      throw StateError('The downloaded CanvasKit version does not support WebParagraph');
+    }
+    return _Bidi!;
+  }
 
   external SkParagraphBuilderNamespace get ParagraphBuilder;
   external SkParagraphStyle ParagraphStyle(SkParagraphStyleProperties properties);
@@ -1849,16 +1854,6 @@
       _reorderVisual(visuals.toJS).toDart.cast<BidiIndex>();
 }
 
-extension type CodeUnitInfo(JSObject _) implements JSObject {
-  external int get flags;
-}
-
-extension type CodeUnitsNamespace(JSObject _) implements JSObject {
-  @JS('compute')
-  external JSArray<JSAny?> _compute(String text);
-  List<CodeUnitInfo> compute(String text) => _compute(text).toDart.cast<CodeUnitInfo>();
-}
-
 extension type SkParagraphBuilderNamespace(JSObject _) implements JSObject {
   external SkParagraphBuilder MakeFromFontCollection(
     SkParagraphStyle paragraphStyle,
@@ -2425,21 +2420,25 @@
 
 const String _kFullCanvasKitJsFileName = 'canvaskit.js';
 const String _kChromiumCanvasKitJsFileName = 'chromium/canvaskit.js';
-const String _kWebParagraphCanvasKitJsFileName = 'experimental_webparagraph/canvaskit.js';
+const String _kWebParagraphCanvasKitJsFileName = 'webparagraph/canvaskit.js';
 
 String get _canvasKitBaseUrl => configuration.canvasKitBaseUrl;
 
 @visibleForTesting
 List<String> getCanvasKitJsFileNames(CanvasKitVariant variant) {
-  return switch (variant) {
-    CanvasKitVariant.auto => <String>[
-      if (_enableCanvasKitChromiumInAutoMode) _kChromiumCanvasKitJsFileName,
-      _kFullCanvasKitJsFileName,
-    ],
-    CanvasKitVariant.full => <String>[_kFullCanvasKitJsFileName],
-    CanvasKitVariant.chromium => <String>[_kChromiumCanvasKitJsFileName],
-    CanvasKitVariant.experimentalWebParagraph => <String>[_kWebParagraphCanvasKitJsFileName],
-  };
+  if (isWebParagraphEnabled) {
+    return <String>[_kWebParagraphCanvasKitJsFileName];
+  }
+  return [
+    ...switch (variant) {
+      CanvasKitVariant.auto => <String>[
+        if (_enableCanvasKitChromiumInAutoMode) _kChromiumCanvasKitJsFileName,
+        _kFullCanvasKitJsFileName,
+      ],
+      CanvasKitVariant.full => <String>[_kFullCanvasKitJsFileName],
+      CanvasKitVariant.chromium => <String>[_kChromiumCanvasKitJsFileName],
+    },
+  ];
 }
 
 Iterable<String> get _canvasKitJsUrls {
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart
index ee49833..e9e9819 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart
@@ -11,8 +11,7 @@
 import 'package:ui/ui.dart' as ui;
 import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
 
-bool get isExperimentalWebParagraph =>
-    configuration.canvasKitVariant == CanvasKitVariant.experimentalWebParagraph;
+bool get isWebParagraphEnabled => configuration.preferWebParagraph && browserSupportsWebParagraph;
 
 class CanvasKitRenderer extends Renderer {
   static CanvasKitRenderer get instance => _instance;
@@ -26,7 +25,7 @@
   /// Whether the renderer is using software rendering.
   bool get isSoftware => _pictureToImageSurface.isSoftware;
 
-  late final FlutterFontCollection _fontCollection = isExperimentalWebParagraph
+  late final FlutterFontCollection _fontCollection = isWebParagraphEnabled
       ? WebFontCollection()
       : SkiaFontCollection();
 
@@ -332,7 +331,7 @@
     List<ui.Shadow>? shadows,
     List<ui.FontFeature>? fontFeatures,
     List<ui.FontVariation>? fontVariations,
-  }) => isExperimentalWebParagraph
+  }) => isWebParagraphEnabled
       ? WebTextStyle(
           color: color,
           decoration: decoration,
@@ -394,7 +393,7 @@
     ui.StrutStyle? strutStyle,
     String? ellipsis,
     ui.Locale? locale,
-  }) => isExperimentalWebParagraph
+  }) => isWebParagraphEnabled
       ? WebParagraphStyle(
           textAlign: textAlign,
           textDirection: textDirection,
@@ -435,7 +434,7 @@
     ui.FontWeight? fontWeight,
     ui.FontStyle? fontStyle,
     bool? forceStrutHeight,
-  }) => isExperimentalWebParagraph
+  }) => isWebParagraphEnabled
       ? WebStrutStyle(
           fontFamily: fontFamily,
           fontFamilyFallback: fontFamilyFallback,
@@ -461,7 +460,7 @@
 
   @override
   ui.ParagraphBuilder createParagraphBuilder(ui.ParagraphStyle style) =>
-      isExperimentalWebParagraph ? WebParagraphBuilder(style) : CkParagraphBuilder(style);
+      isWebParagraphEnabled ? WebParagraphBuilder(style) : CkParagraphBuilder(style);
 
   @override
   WebParagraphPainter createWebParagraphPainter(WebParagraph paragraph) =>
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart
index cb350d8..ad12566 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart
@@ -63,12 +63,6 @@
   /// WARNING: In most cases, you should use [auto] instead of this variant. Using
   /// this variant in a non-Chromium browser will result in a broken app.
   chromium,
-
-  /// The variant that contains the new WebParagraph implementation on top of Chrome's Text Clusters
-  /// API: https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md
-  ///
-  /// WARNING: This is an experimental variant that's not yet ready for production use.
-  experimentalWebParagraph,
 }
 
 /// The Web Engine configuration for the current application.
@@ -312,6 +306,14 @@
     return maxSurfaces;
   }
 
+  /// Enables the new WebParagraph implementation built on top of Chrome's Text Clusters
+  /// API: https://github.com/fserb/canvas2D/blob/master/spec/enhanced-textmetrics.md
+  ///
+  /// If the browser doesn't support WebParagraph, this flag will have no effect.
+  ///
+  /// WARNING: This is an experimental feature.
+  bool get preferWebParagraph => _configuration?.preferWebParagraph ?? false;
+
   /// Set this flag to `true` to cause the engine to visualize the semantics tree
   /// on the screen for debugging.
   ///
@@ -402,6 +404,7 @@
   external bool? get canvasKitForceCpuOnly;
   external bool? get canvasKitForceMultiSurfaceRasterizer;
   external double? get canvasKitMaximumSurfaces;
+  external bool? get preferWebParagraph;
   external bool? get debugShowSemanticsNodes;
   external DomElement? get hostElement;
   external bool? get multiViewEnabled;
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
index 35605c8..dbe8c12 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
@@ -2702,6 +2702,11 @@
   external int get length;
 }
 
+@JS('window.TextCluster')
+external JSAny? get _textClusterConstructor;
+
+bool browserSupportsTextCluster = _textClusterConstructor != null;
+
 @JS('TextCluster')
 extension type DomTextCluster._(JSObject _) implements JSObject {
   @JS('begin')
diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
index ceb6750..fda7d75 100644
--- a/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_test.dart
@@ -1839,6 +1839,56 @@
       expect(getCanvasKitJsFileNames(CanvasKitVariant.chromium), <String>['chromium/canvaskit.js']);
       expect(getCanvasKitJsFileNames(CanvasKitVariant.auto), <String>['canvaskit.js']);
     });
+
+    test('with preferWebParagraph enabled and supported', () {
+      v8BreakIterator = Object().toJSBox; // Any non-null value.
+      intlSegmenter = Object().toJSBox; // Any non-null value.
+      browserSupportsImageDecoder = true;
+      browserSupportsTextCluster = true;
+
+      debugOverrideJsConfiguration(
+        <String, Object?>{'preferWebParagraph': true}.jsify() as JsFlutterConfiguration?,
+      );
+
+      try {
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.full), <String>[
+          'webparagraph/canvaskit.js',
+        ]);
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.chromium), <String>[
+          'webparagraph/canvaskit.js',
+        ]);
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.auto), <String>[
+          'webparagraph/canvaskit.js',
+        ]);
+      } finally {
+        debugOverrideJsConfiguration(null);
+        browserSupportsTextCluster = false;
+      }
+    });
+
+    test('with preferWebParagraph enabled but NOT supported', () {
+      v8BreakIterator = Object().toJSBox; // Any non-null value.
+      intlSegmenter = Object().toJSBox; // Any non-null value.
+      browserSupportsImageDecoder = true;
+      browserSupportsTextCluster = false; // Not supported!
+
+      debugOverrideJsConfiguration(
+        <String, Object?>{'preferWebParagraph': true}.jsify() as JsFlutterConfiguration?,
+      );
+
+      try {
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.full), <String>['canvaskit.js']);
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.chromium), <String>[
+          'chromium/canvaskit.js',
+        ]);
+        expect(getCanvasKitJsFileNames(CanvasKitVariant.auto), <String>[
+          'chromium/canvaskit.js',
+          'canvaskit.js',
+        ]);
+      } finally {
+        debugOverrideJsConfiguration(null);
+      }
+    });
   });
 
   test('respects actual location of canvaskit files', () {
diff --git a/engine/src/flutter/lib/web_ui/test/felt_config.yaml b/engine/src/flutter/lib/web_ui/test/felt_config.yaml
index d564332..e79794f 100644
--- a/engine/src/flutter/lib/web_ui/test/felt_config.yaml
+++ b/engine/src/flutter/lib/web_ui/test/felt_config.yaml
@@ -19,7 +19,7 @@
 
 test-sets:
   # Tests for the new WebParagraph
-  - name: experimental-webparagraph
+  - name: webparagraph
     directory: webparagraph
 
   # Tests for non-renderer logic
@@ -43,8 +43,8 @@
     directory: fallbacks
 
 test-bundles:
-  - name: dart2js-canvaskit-experimental-webparagraph
-    test-set: experimental-webparagraph
+  - name: dart2js-canvaskit-webparagraph
+    test-set: webparagraph
     compile-configs: dart2js-canvaskit
 
   - name: dart2js-canvaskit-engine
@@ -78,14 +78,14 @@
       - dart2js-canvaskit
 
 run-configs:
-  - name: chrome-experimental-webparagraph
+  - name: chrome-webparagraph
     browser: chrome
     browser-flags:
       - --enable-experimental-web-platform-features
       # This is supposed to be the right flag, but for some reason it doesn't work at the moment so
       # we are using the more general one above.
       # - --enable-features=ExtendedTextMetrics
-    canvaskit-variant: experimentalWebParagraph
+    enable-webparagraph: true
 
   - name: chrome
     browser: chrome
@@ -142,10 +142,10 @@
 
 
 test-suites:
-  - name: chrome-dart2js-experimental-webparagraph-ui
-    test-bundle: dart2js-canvaskit-experimental-webparagraph
-    run-config: chrome-experimental-webparagraph
-    artifact-deps: [ canvaskit_experimental_webparagraph ]
+  - name: chrome-dart2js-webparagraph-ui
+    test-bundle: dart2js-canvaskit-webparagraph
+    run-config: chrome-webparagraph
+    artifact-deps: [ canvaskit_webparagraph ]
 
   - name: chrome-dart2wasm-wimp-ui
     test-bundle: dart2wasm-skwasm-ui
diff --git a/engine/src/flutter/third_party/canvaskit/BUILD.gn b/engine/src/flutter/third_party/canvaskit/BUILD.gn
index abe4ea0..92536dd 100644
--- a/engine/src/flutter/third_party/canvaskit/BUILD.gn
+++ b/engine/src/flutter/third_party/canvaskit/BUILD.gn
@@ -65,8 +65,8 @@
   ]
 }
 
-# This toolchain is only to be used by canvaskit_experimental_webparagraph_group below.
-wasm_toolchain("canvaskit_experimental_webparagraph") {
+# This toolchain is only to be used by canvaskit_webparagraph_group below.
+wasm_toolchain("canvaskit_webparagraph") {
   extra_toolchain_args = {
     # In Chromium browsers, we can use the browser's APIs to get the necessary
     # ICU data.
@@ -99,18 +99,16 @@
   }
 }
 
-copy("canvaskit_experimental_webparagraph_group") {
+copy("canvaskit_webparagraph_group") {
   visibility = [ "//flutter/web_sdk:*" ]
-  public_deps = [
-    "//flutter/skia/modules/canvaskit(:canvaskit_experimental_webparagraph)",
-  ]
+  public_deps = [ "//flutter/skia/modules/canvaskit(:canvaskit_webparagraph)" ]
 
   sources = [
-    "$root_out_dir/canvaskit_experimental_webparagraph/canvaskit.js",
-    "$root_out_dir/canvaskit_experimental_webparagraph/canvaskit.js.symbols",
-    "$root_out_dir/canvaskit_experimental_webparagraph/canvaskit.wasm",
+    "$root_out_dir/canvaskit_webparagraph/canvaskit.js",
+    "$root_out_dir/canvaskit_webparagraph/canvaskit.js.symbols",
+    "$root_out_dir/canvaskit_webparagraph/canvaskit.wasm",
   ]
-  outputs = [ "$root_out_dir/flutter_web_sdk/canvaskit/experimental_webparagraph/{{source_file_part}}" ]
+  outputs = [ "$root_out_dir/flutter_web_sdk/canvaskit/webparagraph/{{source_file_part}}" ]
 }
 
 # This toolchain is only to be used by skwasm_group below.
diff --git a/engine/src/flutter/web_sdk/BUILD.gn b/engine/src/flutter/web_sdk/BUILD.gn
index 679f053..a7bd3f4 100644
--- a/engine/src/flutter/web_sdk/BUILD.gn
+++ b/engine/src/flutter/web_sdk/BUILD.gn
@@ -402,8 +402,8 @@
     if (is_wasm) {
       deps += [
         "//flutter/third_party/canvaskit:canvaskit_chromium_group",
-        "//flutter/third_party/canvaskit:canvaskit_experimental_webparagraph_group",
         "//flutter/third_party/canvaskit:canvaskit_group",
+        "//flutter/third_party/canvaskit:canvaskit_webparagraph_group",
         "//flutter/third_party/canvaskit:skwasm_group",
         "//flutter/third_party/canvaskit:skwasm_heavy_group",
         "//flutter/third_party/canvaskit:wimp_group",
@@ -428,15 +428,15 @@
         "$root_out_dir/flutter_web_sdk/canvaskit/chromium/canvaskit.js",
         "$root_out_dir/flutter_web_sdk/canvaskit/chromium/canvaskit.js.symbols",
         "$root_out_dir/flutter_web_sdk/canvaskit/chromium/canvaskit.wasm",
-        "$root_out_dir/flutter_web_sdk/canvaskit/experimental_webparagraph/canvaskit.js",
-        "$root_out_dir/flutter_web_sdk/canvaskit/experimental_webparagraph/canvaskit.js.symbols",
-        "$root_out_dir/flutter_web_sdk/canvaskit/experimental_webparagraph/canvaskit.wasm",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm.js",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm.js.symbols",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm.wasm",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm_heavy.js",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm_heavy.js.symbols",
         "$root_out_dir/flutter_web_sdk/canvaskit/skwasm_heavy.wasm",
+        "$root_out_dir/flutter_web_sdk/canvaskit/webparagraph/canvaskit.js",
+        "$root_out_dir/flutter_web_sdk/canvaskit/webparagraph/canvaskit.js.symbols",
+        "$root_out_dir/flutter_web_sdk/canvaskit/webparagraph/canvaskit.wasm",
         "$root_out_dir/flutter_web_sdk/canvaskit/wimp.js",
         "$root_out_dir/flutter_web_sdk/canvaskit/wimp.js.symbols",
         "$root_out_dir/flutter_web_sdk/canvaskit/wimp.wasm",