[dart2wasm] Take advantage of fast `js-string` builtins

Some wasm engines have started to optimize the `js-string` builtin
proposal (e.g. V8) and those that haven't yet are probaly going to
do soon.

So we can start taking advantage of it in dart2wasm.

=> We make use of them in the JS<->Dart string copy code.

=> This will also provide a better baseline when evaluating whether
   switching to JS stringes entirely makes sense.

A somewhat unrelated (but necessary for this CL) change is to tighten
the types we use in `@pragma('wasm:import')` and
`@pragma('wasm:export')` in some cases:

We should only use 'pure' wasm types (i.e. not wasm struct / function
types we define for dart classes & functions) and mostly non-composed
types in import/exports as the `--closed-world` optimizations from
binaryen rely on that (and error otherwise).

Overall this leads to significant improvements in Dart<->JS
string copies.

The `WasmDataTransfer.*{From,To}BrowserString` benchmarks
improve something between 50-100%.

Change-Id: I2048113c462ecb2047402c0616d2b3b1f45773f5
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/400641
Commit-Queue: Martin Kustermann <kustermann@google.com>
Reviewed-by: Slava Egorov <vegorov@google.com>
diff --git a/pkg/dart2wasm/lib/js/inline_expander.dart b/pkg/dart2wasm/lib/js/inline_expander.dart
index 1e79c09..089d8d7 100644
--- a/pkg/dart2wasm/lib/js/inline_expander.dart
+++ b/pkg/dart2wasm/lib/js/inline_expander.dart
@@ -88,7 +88,7 @@
       String parameterString = 'x$j';
       DartType type = originalArgument.getStaticType(_staticTypeContext);
       dartPositionalParameters.add(VariableDeclaration(parameterString,
-          type: type, isSynthesized: true));
+          type: _toExternalType(type), isSynthesized: true));
       if (originalArgument is! VariableGet) {
         allArgumentsAreGet = false;
       }
@@ -121,4 +121,18 @@
     _methodCollector.addMethod(dartProcedure, jsMethodName, codeTemplate);
     return result;
   }
+
+  // The `JS<foo>("...some js code ...", arg0, arg1)` expressions will produce
+  // wasm imports. We want to only use types in those wasm imports that binaryen
+  // allows under closed world assumptions (and not blindly use `arg<N>`s static
+  // type).
+  //
+  // For now we special case `WasmArray<>` which we turn into a generic `array`
+  // type (super type of all wasm arrays).
+  DartType _toExternalType(DartType type) {
+    if (type is InterfaceType && type.classNode == _util.wasmArrayClass) {
+      return InterfaceType(_util.wasmArrayRefClass, type.declaredNullability);
+    }
+    return type;
+  }
 }
diff --git a/pkg/dart2wasm/lib/js/runtime_blob.dart b/pkg/dart2wasm/lib/js/runtime_blob.dart
index a6ab0e9..76697fb 100644
--- a/pkg/dart2wasm/lib/js/runtime_blob.dart
+++ b/pkg/dart2wasm/lib/js/runtime_blob.dart
@@ -129,6 +129,26 @@
       "fromCharCode": (i) => String.fromCharCode(i),
       "length": (s) => s.length,
       "substring": (s, a, b) => s.substring(a, b),
+      "fromCharCodeArray": (a, start, end) => {
+        if (end <= start) return '';
+
+        const read = dartInstance.exports.$wasmI16ArrayGet;
+        let result = '';
+        let index = start;
+        const chunkLength = Math.min(end - index, 500);
+        let array = new Array(chunkLength);
+        while (index < end) {
+          const newChunkLength = Math.min(end - index, 500);
+          for (let i = 0; i < newChunkLength; i++) {
+            array[i] = read(a, index++);
+          }
+          if (newChunkLength < chunkLength) {
+            array = array.slice(0, newChunkLength);
+          }
+          result += String.fromCharCode(...array);
+        }
+        return result;
+      },
     };
 
     const deferredLibraryHelper = {
diff --git a/pkg/dart2wasm/lib/js/util.dart b/pkg/dart2wasm/lib/js/util.dart
index 0f9f6ae..d2008c1 100644
--- a/pkg/dart2wasm/lib/js/util.dart
+++ b/pkg/dart2wasm/lib/js/util.dart
@@ -27,6 +27,8 @@
   final Procedure jsValueUnboxTarget;
   final Procedure numToIntTarget;
   final Class wasmExternRefClass;
+  final Class wasmArrayClass;
+  final Class wasmArrayRefClass;
   final Procedure wrapDartFunctionTarget;
 
   CoreTypesUtil(this.coreTypes, this._extensionIndex)
@@ -64,6 +66,9 @@
             .firstWhere((p) => p.name.text == 'unbox'),
         wasmExternRefClass =
             coreTypes.index.getClass('dart:_wasm', 'WasmExternRef'),
+        wasmArrayClass = coreTypes.index.getClass('dart:_wasm', 'WasmArray'),
+        wasmArrayRefClass =
+            coreTypes.index.getClass('dart:_wasm', 'WasmArrayRef'),
         wrapDartFunctionTarget = coreTypes.index
             .getTopLevelProcedure('dart:_js_helper', '_wrapDartFunction');
 
diff --git a/pkg/dart2wasm/lib/translator.dart b/pkg/dart2wasm/lib/translator.dart
index 8e38d8c..37cca50 100644
--- a/pkg/dart2wasm/lib/translator.dart
+++ b/pkg/dart2wasm/lib/translator.dart
@@ -690,7 +690,7 @@
   /// (`anyref`, `funcref` or `externref`).
   /// This function can be called before the class info is built.
   w.ValueType translateExternalType(DartType type) {
-    bool isPotentiallyNullable = type.isPotentiallyNullable;
+    final bool isPotentiallyNullable = type.isPotentiallyNullable;
     if (type is InterfaceType) {
       Class cls = type.classNode;
       if (cls == wasmFuncRefClass || cls == wasmFunctionClass) {
@@ -699,6 +699,16 @@
       if (cls == wasmExternRefClass) {
         return w.RefType.extern(nullable: isPotentiallyNullable);
       }
+      if (cls == wasmArrayRefClass) {
+        return w.RefType.array(nullable: isPotentiallyNullable);
+      }
+      if (cls == wasmArrayClass) {
+        final elementType =
+            translateExternalStorageType(type.typeArguments.single);
+        return w.RefType.def(
+            wasmArrayType(elementType, '$elementType', mutable: true),
+            nullable: isPotentiallyNullable);
+      }
       if (!isPotentiallyNullable) {
         w.StorageType? builtin = builtinTypes[cls];
         if (builtin != null && builtin.isPrimitive) {
@@ -711,6 +721,22 @@
     return w.RefType.any(nullable: true);
   }
 
+  w.StorageType translateExternalStorageType(DartType type) {
+    if (type is InterfaceType) {
+      final cls = type.classNode;
+      if (isWasmType(cls)) {
+        final isNullable = type.isPotentiallyNullable;
+        final w.StorageType? builtin = builtinTypes[cls];
+        if (builtin != null) {
+          if (!isNullable) return builtin;
+          if (builtin.isPrimitive) throw "Wasm numeric types can't be nullable";
+          return (builtin as w.RefType).withNullability(isNullable);
+        }
+      }
+    }
+    return translateExternalType(type) as w.RefType;
+  }
+
   /// Creates a global reference to [f] in [module]. [f] must also be located
   /// in [module].
   w.Global makeFunctionRef(w.ModuleBuilder module, w.BaseFunction f) {
diff --git a/sdk/lib/_internal/wasm/lib/js_helper_patch.dart b/sdk/lib/_internal/wasm/lib/js_helper_patch.dart
index 7bdd6ba..1ddd5e8 100644
--- a/sdk/lib/_internal/wasm/lib/js_helper_patch.dart
+++ b/sdk/lib/_internal/wasm/lib/js_helper_patch.dart
@@ -15,63 +15,22 @@
 @pragma('wasm:prefer-inline')
 JSStringImpl jsStringFromDartString(String s) {
   if (s is OneByteString) {
+    final fromArray = s.array;
+    final toArray = WasmArray<WasmI16>(fromArray.length);
+    for (int i = 0; i < fromArray.length; ++i) {
+      toArray.write(i, fromArray.readUnsigned(i));
+    }
     return JSStringImpl(
-      JS<WasmExternRef>(
-        r'''
-      (s, length) => {
-        if (length == 0) return '';
-
-        const read = dartInstance.exports.$stringRead1;
-        let result = '';
-        let index = 0;
-        const chunkLength = Math.min(length - index, 500);
-        let array = new Array(chunkLength);
-        while (index < length) {
-          const newChunkLength = Math.min(length - index, 500);
-          for (let i = 0; i < newChunkLength; i++) {
-            array[i] = read(s, index++);
-          }
-          if (newChunkLength < chunkLength) {
-            array = array.slice(0, newChunkLength);
-          }
-          result += String.fromCharCode(...array);
-        }
-        return result;
-      }
-      ''',
-        jsObjectFromDartObject(s),
-        s.length.toWasmI32(),
+      _jsStringFromCharCodeArray(
+        toArray,
+        0.toWasmI32(),
+        toArray.length.toWasmI32(),
       ),
     );
   }
   if (s is TwoByteString) {
     return JSStringImpl(
-      JS<WasmExternRef>(
-        r'''
-    (s, length) => {
-      if (length == 0) return '';
-
-      const read = dartInstance.exports.$stringRead2;
-      let result = '';
-      let index = 0;
-      const chunkLength = Math.min(length - index, 500);
-      let array = new Array(chunkLength);
-      while (index < length) {
-        const newChunkLength = Math.min(length - index, 500);
-        for (let i = 0; i < newChunkLength; i++) {
-          array[i] = read(s, index++);
-        }
-        if (newChunkLength < chunkLength) {
-          array = array.slice(0, newChunkLength);
-        }
-        result += String.fromCharCode(...array);
-      }
-      return result;
-    }
-    ''',
-        jsObjectFromDartObject(s),
-        s.length.toWasmI32(),
-      ),
+      _jsStringFromCharCodeArray(s.array, 0.toWasmI32(), s.length.toWasmI32()),
     );
   }
 
@@ -79,70 +38,34 @@
 }
 
 @patch
-@pragma('wasm:prefer-inline')
-String jsStringToDartString(JSStringImpl s) {
-  final length = s.length;
-  if (length == 0) return '';
-
-  return JS<String>(r'''
-    (s) => {
-      let length = s.length;
-      let range = 0;
-      for (let i = 0; i < length; i++) {
-        range |= s.codePointAt(i);
-      }
-      const exports = dartInstance.exports;
-      if (range < 256) {
-        if (length <= 10) {
-          if (length == 1) {
-            return exports.$stringAllocate1_1(s.codePointAt(0));
-          }
-          if (length == 2) {
-            return exports.$stringAllocate1_2(s.codePointAt(0), s.codePointAt(1));
-          }
-          if (length == 3) {
-            return exports.$stringAllocate1_3(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2));
-          }
-          if (length == 4) {
-            return exports.$stringAllocate1_4(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3));
-          }
-          if (length == 5) {
-            return exports.$stringAllocate1_5(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4));
-          }
-          if (length == 6) {
-            return exports.$stringAllocate1_6(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4), s.codePointAt(5));
-          }
-          if (length == 7) {
-            return exports.$stringAllocate1_7(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4), s.codePointAt(5), s.codePointAt(6));
-          }
-          if (length == 8) {
-            return exports.$stringAllocate1_8(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4), s.codePointAt(5), s.codePointAt(6), s.codePointAt(7));
-          }
-          if (length == 9) {
-            return exports.$stringAllocate1_9(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4), s.codePointAt(5), s.codePointAt(6), s.codePointAt(7), s.codePointAt(8));
-          }
-          if (length == 10) {
-            return exports.$stringAllocate1_10(s.codePointAt(0), s.codePointAt(1), s.codePointAt(2), s.codePointAt(3), s.codePointAt(4), s.codePointAt(5), s.codePointAt(6), s.codePointAt(7), s.codePointAt(8), s.codePointAt(9));
-          }
-        }
-        const dartString = exports.$stringAllocate1(length);
-        const write = exports.$stringWrite1;
-        for (let i = 0; i < length; i++) {
-          write(dartString, i, s.codePointAt(i));
-        }
-        return dartString;
-      } else {
-        const dartString = exports.$stringAllocate2(length);
-        const write = exports.$stringWrite2;
-        for (let i = 0; i < length; i++) {
-          write(dartString, i, s.charCodeAt(i));
-        }
-        return dartString;
-      }
+String jsStringToDartString(JSStringImpl jsString) {
+  final length = jsString.length;
+  two_byte:
+  {
+    final oneByteString = OneByteString.withLength(length);
+    final array = oneByteString.array;
+    for (int i = 0; i < length; ++i) {
+      final int codeUnit = jsString.codeUnitAtUnchecked(i);
+      if (codeUnit > 255) break two_byte;
+      array.write(i, codeUnit);
     }
-    ''', s.toExternRef);
+    return oneByteString;
+  }
+  final twoByteString = TwoByteString.withLength(length);
+  final array = twoByteString.array;
+  for (int i = 0; i < length; ++i) {
+    array.write(i, jsString.codeUnitAtUnchecked(i));
+  }
+  return twoByteString;
 }
 
+@pragma("wasm:import", "wasm:js-string.fromCharCodeArray")
+external WasmExternRef _jsStringFromCharCodeArray(
+  WasmArray<WasmI16> array,
+  WasmI32 start,
+  WasmI32 end,
+);
+
 @pragma('wasm:prefer-inline')
 void _copyFromWasmI8Array(
   WasmExternRef jsArray,
@@ -541,219 +464,6 @@
   );
 }
 
-@pragma("wasm:export", "\$stringAllocate1")
-OneByteString _stringAllocate1(WasmI32 length) {
-  return OneByteString.withLength(length.toIntSigned());
-}
-
-@pragma("wasm:export", "\$stringAllocate1_1")
-OneByteString _stringAllocate1_1(WasmI32 a0) {
-  final result = OneByteString.withLength(1);
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_2")
-OneByteString _stringAllocate1_2(WasmI32 a0, WasmI32 a1) {
-  final result = OneByteString.withLength(2);
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_3")
-OneByteString _stringAllocate1_3(WasmI32 a0, WasmI32 a1, WasmI32 a2) {
-  final result = OneByteString.withLength(3);
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_4")
-OneByteString _stringAllocate1_4(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-) {
-  final result = OneByteString.withLength(4);
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_5")
-OneByteString _stringAllocate1_5(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-) {
-  final result = OneByteString.withLength(5);
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_6")
-OneByteString _stringAllocate1_6(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-  WasmI32 a5,
-) {
-  final result = OneByteString.withLength(6);
-  result.setUnchecked(5, a5.toIntSigned());
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_7")
-OneByteString _stringAllocate1_7(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-  WasmI32 a5,
-  WasmI32 a6,
-) {
-  final result = OneByteString.withLength(7);
-  result.setUnchecked(6, a6.toIntSigned());
-  result.setUnchecked(5, a5.toIntSigned());
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_8")
-OneByteString _stringAllocate1_8(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-  WasmI32 a5,
-  WasmI32 a6,
-  WasmI32 a7,
-) {
-  final result = OneByteString.withLength(8);
-  result.setUnchecked(7, a7.toIntSigned());
-  result.setUnchecked(6, a6.toIntSigned());
-  result.setUnchecked(5, a5.toIntSigned());
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_9")
-OneByteString _stringAllocate1_9(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-  WasmI32 a5,
-  WasmI32 a6,
-  WasmI32 a7,
-  WasmI32 a8,
-) {
-  final result = OneByteString.withLength(9);
-  result.setUnchecked(8, a8.toIntSigned());
-  result.setUnchecked(7, a7.toIntSigned());
-  result.setUnchecked(6, a6.toIntSigned());
-  result.setUnchecked(5, a5.toIntSigned());
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringAllocate1_10")
-OneByteString _stringAllocate1_10(
-  WasmI32 a0,
-  WasmI32 a1,
-  WasmI32 a2,
-  WasmI32 a3,
-  WasmI32 a4,
-  WasmI32 a5,
-  WasmI32 a6,
-  WasmI32 a7,
-  WasmI32 a8,
-  WasmI32 a9,
-) {
-  final result = OneByteString.withLength(10);
-  result.setUnchecked(9, a9.toIntSigned());
-  result.setUnchecked(8, a8.toIntSigned());
-  result.setUnchecked(7, a7.toIntSigned());
-  result.setUnchecked(6, a6.toIntSigned());
-  result.setUnchecked(5, a5.toIntSigned());
-  result.setUnchecked(4, a4.toIntSigned());
-  result.setUnchecked(3, a3.toIntSigned());
-  result.setUnchecked(2, a2.toIntSigned());
-  result.setUnchecked(1, a1.toIntSigned());
-  result.setUnchecked(0, a0.toIntSigned());
-  return result;
-}
-
-@pragma("wasm:export", "\$stringRead1")
-WasmI32 _stringRead1(WasmExternRef? ref, WasmI32 index) {
-  final string = unsafeCastOpaque<OneByteString>(
-    unsafeCast<WasmExternRef>(ref).internalize(),
-  );
-  return string.codeUnitAtUnchecked(index.toIntSigned()).toWasmI32();
-}
-
-@pragma("wasm:export", "\$stringWrite1")
-void _stringWrite1(WasmExternRef? ref, WasmI32 index, WasmI32 codePoint) {
-  final string = unsafeCastOpaque<OneByteString>(
-    unsafeCast<WasmExternRef>(ref).internalize(),
-  );
-  string.setUnchecked(index.toIntSigned(), codePoint.toIntSigned());
-}
-
-@pragma("wasm:export", "\$stringAllocate2")
-TwoByteString _stringAllocate2(WasmI32 length) {
-  return TwoByteString.withLength(length.toIntSigned());
-}
-
-@pragma("wasm:export", "\$stringRead2")
-WasmI32 _stringRead2(WasmExternRef? ref, WasmI32 index) {
-  final string = unsafeCastOpaque<TwoByteString>(
-    unsafeCast<WasmExternRef>(ref).internalize(),
-  );
-  return string.codeUnitAtUnchecked(index.toIntSigned()).toWasmI32();
-}
-
-@pragma("wasm:export", "\$stringWrite2")
-void _stringWrite2(WasmExternRef? ref, WasmI32 index, WasmI32 codePoint) {
-  final string = unsafeCastOpaque<TwoByteString>(
-    unsafeCast<WasmExternRef>(ref).internalize(),
-  );
-  string.setUnchecked(index.toIntSigned(), codePoint.toIntSigned());
-}
-
 @pragma("wasm:export", "\$wasmI8ArrayGet")
 WasmI32 _wasmI8ArrayGet(WasmExternRef? ref, WasmI32 index) {
   final array = unsafeCastOpaque<WasmArray<WasmI8>>(
diff --git a/sdk/lib/_internal/wasm/lib/js_string.dart b/sdk/lib/_internal/wasm/lib/js_string.dart
index ea32379..cab0284 100644
--- a/sdk/lib/_internal/wasm/lib/js_string.dart
+++ b/sdk/lib/_internal/wasm/lib/js_string.dart
@@ -60,7 +60,6 @@
       final s = o.toString();
       final jsString =
           s is JSStringImpl ? js.JSValue.boxT<JSAny?>(s.toExternRef) : s.toJS;
-      // array._setUnchecked(i, jsString);
       array[i] = jsString;
     }
     return JSStringImpl(
diff --git a/sdk/lib/_internal/wasm/lib/string.dart b/sdk/lib/_internal/wasm/lib/string.dart
index 14f40bd..4219e9f 100644
--- a/sdk/lib/_internal/wasm/lib/string.dart
+++ b/sdk/lib/_internal/wasm/lib/string.dart
@@ -31,6 +31,9 @@
 
   @pragma('wasm:prefer-inline')
   void setUnchecked(int index, int codePoint) => _setAt(index, codePoint);
+
+  @pragma('wasm:prefer-inline')
+  WasmArray<WasmI8> get array => _array;
 }
 
 extension TwoByteStringUncheckedOperations on TwoByteString {
@@ -43,6 +46,9 @@
 
   @pragma('wasm:prefer-inline')
   void setUnchecked(int index, int codePoint) => _setAt(index, codePoint);
+
+  @pragma('wasm:prefer-inline')
+  WasmArray<WasmI16> get array => _array;
 }
 
 /// Static function for `OneByteString._array` to avoid making `_array` public.
diff --git a/tests/web/wasm/string_copy_test.dart b/tests/web/wasm/string_copy_test.dart
new file mode 100644
index 0000000..09f0402
--- /dev/null
+++ b/tests/web/wasm/string_copy_test.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2024, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:js_interop';
+
+import 'package:expect/expect.dart';
+
+main() async {
+  final String oneByteString = makeLongString('012345789');
+  final String twoByteString = makeLongString('01234δΈ­6789');
+
+  Expect.equals(oneByteString, roundTrip(oneByteString));
+  Expect.equals(twoByteString, roundTrip(twoByteString));
+}
+
+/// Ensure we make a very long string, ensuring that we'll also hit slow paths
+/// in the string copy implementation.
+String makeLongString(String string) {
+  while (string.length < 1024 * 1024) {
+    string = string + string;
+  }
+  return string;
+}
+
+/// Copies the string to JS and back again to a dart internal String.
+String roundTrip(String dartString) {
+  final JSString jsString = dartString.toJS;
+
+  // Using string interpolation will force conversion to internal string (vs
+  // `JSStringImpl`)
+  final string = 'A${jsString.toDart}Z';
+  return string.substring(1, string.length - 1);
+}