Migrate a Flutter web app from dart:html to package:web and dart:js_interop
A step-by-step migration off the deprecated dart:html, dart:js_util, and package:js onto package:web 1.1.1 and dart:js_interop: how to find every offending import with the dart2wasm compiler, what dart fix does and does not rename, the JSImmutableListWrapper and innerHTML traps, and how to verify with flutter build web --wasm.
A single-app Flutter web codebase with a handful of dart:html calls is a half-day migration. A codebase where dart:html leaked into shared packages, mocks, or a plugin you maintain is a week, and the long pole is almost never your own code: it is the transitive dependency that still imports the legacy library. Nothing about this is optional any more. dart:html, dart:js, dart:js_util, and package:js were deprecated in Dart 3.7 (February 2025), none of them compile under dart2wasm, and the replacement pair, package:web 1.1.1 plus dart:js_interop, has been stable since July 2024. This guide targets the current stable channel, Flutter 3.47.2 with Dart 3.13.2 (released 2026-08-27), and package:web 1.1.1, which requires Dart ^3.4.0. Every compiler transcript below was captured on a real run with the Flutter 3.44.8 / Dart 3.12.2 stable toolchain and the same package:web 1.1.1.
Why you cannot keep putting this off
- WebAssembly is gated on it.
dart2wasmrefuses to compile a program that transitively reachesdart:html. If you want the payoff described in building a Flutter web app withflutter build web --wasm, this migration is the entry fee, not an optimization. - The deprecation is already load-bearing.
dart analyzereportsdeprecated_member_useon the import line itself, so every CI job with--fatal-infosis already failing or is one config change away from it. package:webis versioned separately from the SDK. Browser API additions ship as a package release instead of waiting for an SDK release, andpackage:webis generated directly from the Web IDL, so names match MDN instead of matching a 2013-era Dart style guide.- If you publish a package, your users cannot compile to Wasm until you move. One
dart:htmlimport in a leaf package blocks the whole dependency graph downstream.
What breaks
| Area | Change | Severity |
|---|---|---|
| Type names | Dart-style names revert to IDL names: HtmlElement becomes HTMLElement, InputElement becomes HTMLInputElement, AnchorElement becomes HTMLAnchorElement | high, but mostly automatable |
| Collections | querySelectorAll and children return NodeList / HTMLCollection, which do not implement List | high |
| Type tests | is and as no longer work on browser types, because every package:web type erases to JSObject | high |
| Mocking | Extension types have no virtual dispatch, so a mock that implements a dart:html class cannot implement a package:web type | high |
| Type signatures | innerHTML is JSAny, event listeners take JSFunction, so call sites need .toJS | medium |
| Zones | Callbacks are no longer bound to the current zone automatically | medium |
| Conditional imports | dart.library.html must become dart.library.js_interop | medium |
| Platform views | View factories must return a package:web element and register through dart:ui_web | medium |
dart:js_util | getProperty / setProperty / callMethod move to dart:js_interop_unsafe with JSAny keys | low, mechanical |
Pre-flight checklist
- Flutter 3.47.2 or newer on the stable channel. Anything from Flutter 3.22 (Dart 3.4) works, but the analyzer fixes below are better in recent SDKs.
flutter pub add web, which resolves toweb: ^1.1.1.- A CI job that runs
flutter build web --wasmeven if you do not ship the Wasm build yet. It is the only reliable detector for legacy imports hiding in dependencies. - A branch, not a series of small commits on
main. The rename pass touches a lot of files at once and is painful to review in slices. - An inventory of packages you depend on that were last published before mid-2024. Those are your likely blockers.
Migration steps
-
Find every offending import with the compiler, not with grep.
grep -r "dart:html" lib/finds your code and misses the dependency three levels down that actually blocks you.dart2wasmprints the full import chain instead. Runflutter build web --wasmand read the first 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:htmlThe “Detailed import paths” block is the useful part. When the chain ends in a pub package rather than your own
lib/, you have found a dependency that has to be upgraded, forked, or replaced before your app can move.Verification: every path printed by the compiler is written down and classified as “my code”, “my package”, or “third-party”. Nothing is left as “probably fine”.
-
Swap the import and add the dependency. Per file,
import 'dart:html' as html;becomesimport 'package:web/web.dart' as web;. Keep the prefix. An unprefixedpackage:webimport drops several hundred top-level names into scope and collides with Flutter’s ownElement,Image, andText.flutter pub add webVerification:
flutter pub deps | grep webshowsweb 1.1.1, and the file’s errors change from “deprecated” to a list of undefined names. Undefined names are progress, they are the rename work made visible. -
Run
dart fixfor the type renames, then finish the rest by hand.package:webships alib/fix_data.yamlwith 141 rename transforms, so the analyzer can rewrite most legacy type names once the new import is in place:dart fix --dry-run dart fix --applyOn a file containing
InputElement,HtmlElement, andCheckboxInputElement,dart fix --applyrewrites the first two and leaves the third alone:// 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 undefinedCheckboxInputElementis not a rename, it is adart:htmlconvenience type with no IDL counterpart. The manual form isHTMLInputElement()..type = 'checkbox'. Where a name has no transform, look up the@Nativeannotation on the olddart:htmlclass: its value is thepackage:webname.Verification:
dart analyzereports zeroundefined_classandundefined_functiondiagnostics in the migrated files. -
Replace
dart:js_utilandpackage:jswithdart:js_interop. The old dynamic accessors move todart:js_interop_unsafeand takeJSAnykeys instead ofString. Declared interop moves from@JS()classes to extension types onJSObject. Before:// 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']); } }After:
// 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); } }Three patterns to internalize:
allowInterop(fn)becomesfn.toJS,js_util.promiseToFuture(p)becomesp.toDart, and aJSPromise<T>awaited with.toDartgives you aFuture<T>.HttpRequesthas no direct replacement worth using;window.fetchorpackage:httpis the answer.Verification:
dart analyzeis clean, and no file in the repo still importsdart:js,dart:js_util, orpackage:js. -
Move platform view factories to
dart:ui_web. Any code registering an HTML view has to return apackage:webelement now. The registry lives indart:ui_web, andregisterViewFactoryis declared asregisterViewFactory(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); }Verification: the view renders in
flutter run -d chrome, andflutter build web --wasmcompiles the file without complaint. -
Rewrite conditional imports to key off
dart.library.js_interop. The old spelling silently selects the stub implementation underdart2wasmbecausedart.library.htmlis false there, which produces anUnsupportedErrorat runtime instead of a compile error. That is the worst failure mode in this whole migration:// 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');Verification: grep the repo for
dart.library.htmland confirm zero hits, then run the app on both a native target and the web to prove each branch still resolves. The same technique applies to the wider problem of platform-specific code without a plugin. -
Fix the tests last, because mocks break differently.
package:webtypes are extension types overJSObject, so a fake thatimplements HTMLElementwill not compile. Replace class-based fakes with real DOM nodes created in the test, or with a JS object you build and hand to the code under test. Anything that reached fordynamicto call a DOM member also stops working, because extension type members resolve statically only.Verification:
flutter testpasses with noimplementsclause pointing at apackage:webtype left in the suite.
Verification
Run all four, in this order:
dart analyze --fatal-infos
flutter test
flutter build web
flutter build web --wasm
The last command is the real gate. On a migrated app it ends with Built build/web and drops main.dart.wasm, main.dart.mjs, and the dart2js fallback main.dart.js into build/web. If it still fails, the error names the exact import chain that is left. After that, load the app and click through anything that touches the DOM: file downloads, clipboard, iframes, localStorage, and any JS SDK you talk to through interop.
Rollback plan
Per-file rollback is easy and per-repo rollback is not worth planning for. package:web and dart:html can coexist in the same program, so you can migrate one file, ship it, and revert that file alone if something breaks. What you cannot do is roll back after you have deleted the dart:html code paths and shipped a Wasm build, because the Wasm build never supported them in the first place. Keep the dart2js build as your production target until the click-through pass above is done; flutter build web --wasm emits both, and the loader falls back on its own.
Gotchas worth knowing before you start
The official JSImmutableListWrapper example does not compile. JSImmutableListWrapper<T, U> cannot infer U from its constructor argument, so it falls back to the bound, JSObject:
for (final a in JSImmutableListWrapper(document.querySelectorAll('a'))) {
a.classList.add('link'); // error: The getter 'classList' isn't defined for the type 'JSObject'
}
Pass both type arguments explicitly:
// package:web 1.1.1
for (final a in JSImmutableListWrapper<NodeList, Element>(
document.querySelectorAll('a'),
)) {
a.classList.add('link');
}
innerHTML is JSAny, in both directions. Writing needs .toJS, and reading needs a cast: final String s = el.innerHTML; fails with “A value of type ‘JSAny’ can’t be assigned to a variable of type ‘String’”. Read it as (el.innerHTML as JSString).toDart. The same applies to outerHTML and to insertAdjacentHTML, whose second parameter is JSAny.
element.text is a setter with no getter. package:web keeps a deprecated text setter for migration convenience, but reading requires textContent, which is String? rather than String. Code that did if (el.text.isEmpty) needs a null check now.
Callbacks lose their zone. dart:html bound event callbacks to the current zone automatically; package:web does not. If you rely on zone-local values or on a zone-based error handler catching what happens inside a listener, bind manually before converting:
element.addEventListener(
'click',
Zone.current.bindUnaryCallback((Event event) {
// zone-local values are preserved here
}).toJS,
);
Type tests silently change meaning. obj is Window compiled fine under dart:html; under package:web every type erases to JSObject, so the check is meaningless. Use element.isA<HTMLInputElement>() (Dart 3.4 and newer) or obj.instanceOfString('Window').
Some dart:html habits survive as deprecated shims. window.localStorage['k'] = 'v' still analyzes, with ”’[]=’ is deprecated and shouldn’t be used. Use Storage.setItem instead”, and a top-level querySelector exists with “Directly use document.querySelector instead”. They compile today, they are not a destination. Convert them in the same pass or you will do this twice.
Event streams still exist, and they are the ergonomic path. package:web ships stream helpers, so input.onClick.listen(...) works unchanged and returns ElementStream<MouseEvent>. Prefer them over raw addEventListener plus .toJS for anything you need to cancel. Note that the helper streams deliver some events asynchronously where dart:html was synchronous, so timing-sensitive code needs a second look.
Related
- The payoff for this work is described in full in building a Flutter web app with WebAssembly, including why Firefox and Safari still get the JavaScript build.
- Structurally this is the same kind of wide, mechanical pass as migrating a Flutter 2 app to Flutter 3.x: a two-hop plan and a compiler that tells you when you are done.
- The conditional-import mechanism in step 6 is the same one behind platform-specific code without a plugin.
- If you are upgrading Flutter at the same time, read what Flutter 3.47 changed for desktop rendering before blaming this migration for a visual regression.
- Web is also where Dart isolates behave differently from every other platform, which is worth knowing before you move CPU-bound work around during the same pass.
Sources
- 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 on pub.dev, version 1.1.1
- EventStreamProviders API reference, package:web
- dart:ui_web PlatformViewRegistry, Flutter API docs
- Announcing Dart 3.13, the Dart blog
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.