Migra una app web de Flutter de dart:html a package:web y dart:js_interop
Una migración paso a paso desde los obsoletos dart:html, dart:js_util y package:js hacia package:web 1.1.1 y dart:js_interop: cómo encontrar cada import problemático con el compilador dart2wasm, qué renombra y qué no renombra dart fix, las trampas de JSImmutableListWrapper e innerHTML, y cómo verificar con flutter build web --wasm.
Un código web de Flutter de una sola app con unas pocas llamadas a dart:html es una migración de medio día. Un código donde dart:html se filtró a paquetes compartidos, a mocks o a un plugin que tú mismo mantienes es una semana, y el cuello de botella casi nunca es tu propio código: es la dependencia transitiva que sigue importando la biblioteca heredada. Ya nada de esto es opcional. dart:html, dart:js, dart:js_util y package:js quedaron obsoletos en Dart 3.7 (febrero de 2025), ninguno compila bajo dart2wasm, y el par de reemplazo, package:web 1.1.1 junto con dart:js_interop, es estable desde julio de 2024. Esta guía apunta al canal stable actual, Flutter 3.47.2 con Dart 3.13.2 (publicado el 2026-08-27), y a package:web 1.1.1, que requiere Dart ^3.4.0. Cada salida del compilador que aparece abajo se capturó en una ejecución real con el toolchain stable Flutter 3.44.8 / Dart 3.12.2 y el mismo package:web 1.1.1.
Por qué ya no puedes seguir postergándolo
- WebAssembly depende de esto.
dart2wasmse niega a compilar un programa que alcancedart:htmlde forma transitiva. Si quieres el beneficio descrito en compilar una app web de Flutter conflutter build web --wasm, esta migración es el precio de entrada, no una optimización. - La obsolescencia ya pesa.
dart analyzereportadeprecated_member_useen la propia línea del import, así que cualquier job de CI con--fatal-infosya está fallando o está a un cambio de configuración de fallar. package:webse versiona aparte del SDK. Las adiciones a las APIs del navegador llegan como una versión del paquete en lugar de esperar una versión del SDK, ypackage:webse genera directamente desde el Web IDL, así que los nombres coinciden con MDN en vez de con una guía de estilo de Dart de 2013.- Si publicas un paquete, tus usuarios no pueden compilar a Wasm hasta que migres. Un solo import de
dart:htmlen un paquete hoja bloquea todo el grafo de dependencias aguas abajo.
Qué se rompe
| Área | Cambio | Severidad |
|---|---|---|
| Nombres de tipos | Los nombres al estilo Dart vuelven a los nombres del IDL: HtmlElement pasa a HTMLElement, InputElement a HTMLInputElement, AnchorElement a HTMLAnchorElement | alta, pero casi todo automatizable |
| Colecciones | querySelectorAll y children devuelven NodeList / HTMLCollection, que no implementan List | alta |
| Pruebas de tipo | is y as ya no funcionan sobre tipos del navegador, porque todo tipo de package:web se borra a JSObject | alta |
| Mocking | Los extension types no tienen despacho virtual, así que un mock que implements una clase de dart:html no puede implementar un tipo de package:web | alta |
| Firmas de tipos | innerHTML es JSAny, los listeners de eventos reciben JSFunction, así que los call sites necesitan .toJS | media |
| Zonas | Los callbacks ya no se enlazan automáticamente a la zona actual | media |
| Imports condicionales | dart.library.html debe pasar a dart.library.js_interop | media |
| Vistas de plataforma | Las factories de vista deben devolver un elemento de package:web y registrarse a través de dart:ui_web | media |
dart:js_util | getProperty / setProperty / callMethod se mueven a dart:js_interop_unsafe con claves JSAny | baja, mecánica |
Lista previa al despegue
- Flutter 3.47.2 o superior en el canal stable. Cualquier versión desde Flutter 3.22 (Dart 3.4) funciona, pero las correcciones del analizador que se describen abajo son mejores en SDKs recientes.
flutter pub add web, que resuelve aweb: ^1.1.1.- Un job de CI que ejecute
flutter build web --wasmaunque todavía no publiques la build de Wasm. Es el único detector confiable de imports heredados escondidos en dependencias. - Una rama, no una serie de commits pequeños sobre
main. La pasada de renombrado toca muchos archivos a la vez y es dolorosa de revisar en trozos. - Un inventario de los paquetes de los que dependes que se publicaron por última vez antes de mediados de 2024. Esos son tus bloqueadores probables.
Pasos de la migración
-
Encuentra cada import problemático con el compilador, no con grep.
grep -r "dart:html" lib/encuentra tu código y se pierde la dependencia tres niveles más abajo que en realidad te bloquea.dart2wasmimprime la cadena completa de imports. Ejecutaflutter build web --wasmy lee el primer error:Target dart2wasm failed: ProcessException: Process exited abnormally with exit code 254: lib/legacy_bit.dart:1:8: Error: Dart library 'dart:html' is not available on this platform. import 'dart:html' as html; ^ Context: The unavailable library 'dart:html' is imported through these packages: main.dart => package:fweb => dart:html Detailed import paths for (some of) the these imports: main.dart => package:fweb/main.dart => package:fweb/legacy_bit.dart => dart:htmlEl bloque “Detailed import paths” es la parte útil. Cuando la cadena termina en un paquete de pub y no en tu propio
lib/, encontraste una dependencia que hay que actualizar, forkear o reemplazar antes de que tu app pueda migrar.Verificación: cada ruta impresa por el compilador queda anotada y clasificada como “mi código”, “mi paquete” o “de terceros”. Nada queda como “seguramente está bien”.
-
Cambia el import y agrega la dependencia. Por archivo,
import 'dart:html' as html;pasa aimport 'package:web/web.dart' as web;. Conserva el prefijo. Un import depackage:websin prefijo mete varios cientos de nombres de nivel superior en el ámbito y choca conElement,ImageyTextdel propio Flutter.flutter pub add webVerificación:
flutter pub deps | grep webmuestraweb 1.1.1, y los errores del archivo pasan de “deprecated” a una lista de nombres indefinidos. Los nombres indefinidos son progreso: son el trabajo de renombrado hecho visible. -
Ejecuta
dart fixpara los renombrados de tipos y termina el resto a mano.package:webincluye unlib/fix_data.yamlcon 141 transformaciones de renombrado, así que el analizador puede reescribir la mayoría de los nombres de tipos heredados una vez que el nuevo import está en su lugar:dart fix --dry-run dart fix --applySobre un archivo que contiene
InputElement,HtmlElementyCheckboxInputElement,dart fix --applyreescribe los dos primeros y deja el tercero intacto:// After dart fix --apply, package:web 1.1.1 final HTMLInputElement input = HTMLInputElement(); final HTMLElement box = document.querySelector('#box') as HTMLElement; final CheckboxInputElement cb = CheckboxInputElement(); // still undefinedCheckboxInputElementno es un renombrado: es un tipo de conveniencia dedart:htmlsin contraparte en el IDL. La forma manual esHTMLInputElement()..type = 'checkbox'. Cuando un nombre no tiene transformación, busca la anotación@Nativede la clase antigua dedart:html: su valor es el nombre enpackage:web.Verificación:
dart analyzereporta cero diagnósticosundefined_classyundefined_functionen los archivos migrados. -
Reemplaza
dart:js_utilypackage:jspordart:js_interop. Los accesores dinámicos antiguos se mueven adart:js_interop_unsafey reciben clavesJSAnyen vez deString. La interoperabilidad declarada pasa de clases@JS()a extension types sobreJSObject. Antes:// dart:html + dart:js_util, Dart 3.12.2 import 'dart:convert'; import 'dart:html'; import 'dart:js_util' as js_util; void downloadCsv(String csv) { final blob = Blob([csv], 'text/csv'); final url = Url.createObjectUrlFromBlob(blob); AnchorElement(href: url) ..download = 'report.csv' ..click(); Url.revokeObjectUrl(url); } Future<Map<String, dynamic>> loadJson(String path) async { final text = await HttpRequest.getString(path); return jsonDecode(text) as Map<String, dynamic>; } void unsafeAccess() { final maybe = js_util.getProperty(window, 'myLegacyGlobal'); if (maybe != null) { js_util.callMethod(maybe, 'init', ['flutter']); } }Después:
// package:web 1.1.1 + dart:js_interop, Dart 3.12.2 import 'dart:convert'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'package:web/web.dart'; void downloadCsv(String csv) { final blob = Blob([csv.toJS].toJS, BlobPropertyBag(type: 'text/csv')); final url = URL.createObjectURL(blob); final anchor = document.createElement('a') as HTMLAnchorElement ..href = url ..download = 'report.csv'; anchor.click(); URL.revokeObjectURL(url); } Future<Map<String, dynamic>> loadJson(String path) async { final response = await window.fetch(path.toJS).toDart; final text = await response.text().toDart; return jsonDecode(text.toDart) as Map<String, dynamic>; } void unsafeAccess() { final maybe = globalContext.getProperty<JSObject?>('myLegacyGlobal'.toJS); if (maybe != null) { maybe.callMethod<JSAny?>('init'.toJS, 'flutter'.toJS); } }Tres patrones que conviene interiorizar:
allowInterop(fn)pasa afn.toJS,js_util.promiseToFuture(p)pasa ap.toDart, y unaJSPromise<T>esperada con.toDartte da unFuture<T>.HttpRequestno tiene reemplazo directo que valga la pena usar; la respuesta eswindow.fetchopackage:http.Verificación:
dart analyzeestá limpio y ningún archivo del repositorio importa todavíadart:js,dart:js_utilnipackage:js. -
Mueve las factories de vistas de plataforma a
dart:ui_web. Todo código que registre una vista HTML ahora tiene que devolver un elemento depackage:web. El registro vive endart:ui_web, yregisterViewFactoryse declara comoregisterViewFactory(String viewType, Function viewFactory, {bool isVisible = true}):// Flutter 3.44.8, package:web 1.1.1 import 'dart:ui_web' as ui_web; import 'package:flutter/widgets.dart'; import 'package:web/web.dart' as web; const _viewType = 'startdebugging-iframe'; void registerIframeFactory() { ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { final iframe = web.document.createElement('iframe') as web.HTMLIFrameElement ..src = 'https://startdebugging.net/' ..style.border = 'none' ..style.width = '100%' ..style.height = '100%'; return iframe; }); } class EmbeddedSite extends StatelessWidget { const EmbeddedSite({super.key}); @override Widget build(BuildContext context) => const HtmlElementView(viewType: _viewType); }Verificación: la vista se renderiza con
flutter run -d chrome, yflutter build web --wasmcompila el archivo sin quejarse. -
Reescribe los imports condicionales para que dependan de
dart.library.js_interop. La forma antigua selecciona en silencio la implementación stub bajodart2wasm, porque ahídart.library.htmles falso, lo que produce unUnsupportedErroren tiempo de ejecución en vez de un error de compilación. Ese es el peor modo de fallo de toda esta migración:// lib/platform_open.dart, Dart 3.12.2 export 'src/open_stub.dart' if (dart.library.io) 'src/open_io.dart' if (dart.library.js_interop) 'src/open_web.dart';// lib/src/open_web.dart import 'package:web/web.dart' as web; void openUrl(String url) => web.window.open(url, '_blank');Verificación: busca
dart.library.htmlcon grep en el repositorio y confirma cero resultados, luego ejecuta la app en un target nativo y en la web para probar que cada rama sigue resolviéndose. La misma técnica aplica al problema más amplio del código específico de plataforma sin un plugin. -
Arregla las pruebas al final, porque los mocks se rompen distinto. Los tipos de
package:webson extension types sobreJSObject, así que un fake que hagaimplements HTMLElementno compila. Reemplaza los fakes basados en clases por nodos DOM reales creados en la prueba, o por un objeto JS que construyas y le pases al código bajo prueba. Todo lo que recurría adynamicpara llamar a un miembro del DOM también deja de funcionar, porque los miembros de un extension type se resuelven solo de forma estática.Verificación:
flutter testpasa y no queda en la suite ninguna cláusulaimplementsapuntando a un tipo depackage:web.
Verificación
Ejecuta las cuatro, en este orden:
dart analyze --fatal-infos
flutter test
flutter build web
flutter build web --wasm
El último comando es la verdadera barrera. En una app migrada termina con Built build/web y deja main.dart.wasm, main.dart.mjs y el fallback de dart2js main.dart.js en build/web. Si aun así falla, el error nombra la cadena exacta de imports que queda. Después de eso, carga la app y recorre todo lo que toque el DOM: descargas de archivos, portapapeles, iframes, localStorage y cualquier SDK de JS con el que hables por interoperabilidad.
Plan de reversión
La reversión por archivo es fácil y la reversión de todo el repositorio no vale la pena planificarla. package:web y dart:html pueden convivir en el mismo programa, así que puedes migrar un archivo, publicarlo y revertir solo ese archivo si algo se rompe. Lo que no puedes hacer es revertir después de haber borrado las rutas de código con dart:html y publicado una build de Wasm, porque la build de Wasm nunca las soportó. Mantén la build de dart2js como tu target de producción hasta terminar el recorrido manual de arriba; flutter build web --wasm emite ambas y el cargador hace el fallback por su cuenta.
Trampas que conviene conocer antes de empezar
El ejemplo oficial de JSImmutableListWrapper no compila. JSImmutableListWrapper<T, U> no puede inferir U a partir de su argumento de constructor, así que cae al límite del parámetro, JSObject:
for (final a in JSImmutableListWrapper(document.querySelectorAll('a'))) {
a.classList.add('link'); // error: The getter 'classList' isn't defined for the type 'JSObject'
}
Pasa ambos argumentos de tipo de forma explícita:
// package:web 1.1.1
for (final a in JSImmutableListWrapper<NodeList, Element>(
document.querySelectorAll('a'),
)) {
a.classList.add('link');
}
innerHTML es JSAny, en ambas direcciones. Escribir necesita .toJS, y leer necesita un cast: final String s = el.innerHTML; falla con “A value of type ‘JSAny’ can’t be assigned to a variable of type ‘String’”. Léelo como (el.innerHTML as JSString).toDart. Lo mismo aplica a outerHTML y a insertAdjacentHTML, cuyo segundo parámetro es JSAny.
element.text es un setter sin getter. package:web conserva un setter text obsoleto por comodidad durante la migración, pero leer requiere textContent, que es String? en vez de String. El código que hacía if (el.text.isEmpty) ahora necesita una comprobación de null.
Los callbacks pierden su zona. dart:html enlazaba los callbacks de eventos a la zona actual de forma automática; package:web no lo hace. Si dependes de valores locales de la zona o de que un manejador de errores basado en zonas capture lo que ocurre dentro de un listener, enlaza manualmente antes de convertir:
element.addEventListener(
'click',
Zone.current.bindUnaryCallback((Event event) {
// zone-local values are preserved here
}).toJS,
);
Las pruebas de tipo cambian de significado en silencio. obj is Window compilaba bien bajo dart:html; bajo package:web todo tipo se borra a JSObject, así que la comprobación no significa nada. Usa element.isA<HTMLInputElement>() (Dart 3.4 y superior) o obj.instanceOfString('Window').
Algunas costumbres de dart:html sobreviven como shims obsoletos. window.localStorage['k'] = 'v' todavía pasa el análisis, con ”’[]=’ is deprecated and shouldn’t be used. Use Storage.setItem instead”, y existe un querySelector de nivel superior con “Directly use document.querySelector instead”. Compilan hoy, pero no son un destino. Conviértelos en la misma pasada o harás esto dos veces.
Los streams de eventos siguen existiendo y son el camino ergonómico. package:web trae helpers de streams, así que input.onClick.listen(...) funciona sin cambios y devuelve ElementStream<MouseEvent>. Prefiérelos sobre addEventListener crudo más .toJS para todo lo que necesites cancelar. Ten en cuenta que los streams helper entregan algunos eventos de forma asíncrona donde dart:html era síncrono, así que el código sensible al tiempo necesita una segunda revisión.
Relacionado
- El beneficio de este trabajo se describe completo en compilar una app web de Flutter con WebAssembly, incluido por qué Firefox y Safari siguen recibiendo la build de JavaScript.
- Estructuralmente esta es la misma clase de pasada amplia y mecánica que migrar una app de Flutter 2 a Flutter 3.x: un plan de dos saltos y un compilador que te avisa cuando terminaste.
- El mecanismo de imports condicionales del paso 6 es el mismo que está detrás del código específico de plataforma sin un plugin.
- Si además estás actualizando Flutter, lee qué cambió Flutter 3.47 para el renderizado en escritorio antes de culpar a esta migración por una regresión visual.
- La web también es donde los isolates de Dart se comportan distinto que en cualquier otra plataforma, algo que conviene saber antes de mover trabajo intensivo en CPU durante la misma pasada.
Fuentes
- Migrate to package:web, dart.dev
- Past JS interop, dart.dev
- JS types and conversions, dart.dev
- Breaking changes and deprecations, dart.dev
- package:web en pub.dev, versión 1.1.1
- Referencia de la API EventStreamProviders, package:web
- dart:ui_web PlatformViewRegistry, documentación de la API de Flutter
- Announcing Dart 3.13, el blog de Dart
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.