Skip to main content

Migrating from dart:html to package:web for Flutter WASM Builds

 If you have recently attempted to compile your Flutter Web application using the experimental --wasm flag, you likely encountered this build-breaking error:

Error: Dart library 'dart:html' is not available on this platform.

This is not a configuration error. It is a fundamental architecture mismatch. You cannot simply polyfill dart:html for WebAssembly. To support WasmGC (Garbage Collection), you must migrate your codebase from the legacy dart:html library to the modern package:web.

The Architecture of the Failure

To understand why your build failed, you must understand how Flutter compiles for the web.

Historically, Flutter Web used dart2js or ddc (Dart Dev Compiler). These compilers relied on dart:html, a library that essentially mapped Dart classes to DOM objects using a dynamic, "magic" interop layer suited specifically for JavaScript generation.

WebAssembly (Wasm) changes the rules.

The dart2wasm compiler generates WasmGC instructions. Wasm is strongly typed and statically analyzed. The dynamic nature of dart:html—which relies heavily on runtime type checking and specific JavaScript compilation hacks—cannot be translated to the strict memory model of WebAssembly.

To solve this, the Dart team introduced Static Interop (dart:js_interop) and Extension Types (introduced in Dart 3.3).

package:web is the implementation of these new concepts. It provides strictly typed, zero-cost bindings to Browser APIs (generated from standard Web IDL) that work for both JavaScript and WebAssembly targets.

The Fix: Step-by-Step Migration

We will migrate a typical scenario: accessing the global window object, manipulating the DOM, and using LocalStorage.

1. Update Dependencies

First, remove dart:html references (if explicit) and add web. Ensure your pubspec.yaml requires a Dart SDK version of at least 3.3.0 (required for Extension Types).

environment:
  sdk: '>=3.3.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  # Add this
  web: ^0.5.1

Run flutter pub get.

2. The Migration Implementation

Below is a comparison of the legacy approach versus the modern package:web implementation.

The Legacy Approach (Broken in Wasm)

This code relies on dart:html, which exposes a global window object and uses Dart-specific wrappers (like InputElement) that don't map 1:1 to JS objects in Wasm.

// OLD CODE - DO NOT USE
import 'dart:html';

void saveToLocalStorage(String key, String value) {
  // dart:html exposed a global 'window' variable
  window.localStorage[key] = value;
}

void updateInput(String selector, String value) {
  // Dynamic casting that fails in Wasm
  final input = querySelector(selector) as InputElement?;
  input?.value = value;
}

The Modern Approach (Wasm Ready)

Create a new file (e.g., web_interop.dart). We will use package:web combined with dart:js_interop.

Key differences to note:

  1. Imports: We import package:web/web.dart instead of dart:html.
  2. Helpers: We use web.window to access the global scope.
  3. Type Safety: We use explicit casting via extension types rather than Dart's runtime as.
import 'package:flutter/foundation.dart'; // For kIsWeb
import 'package:web/web.dart' as web;
import 'dart:js_interop';

/// Safe wrapper to save data, compatible with both JS and Wasm runtimes
void saveToLocalStorage(String key, String value) {
  // Check if we are actually on the web to prevent crash on Mobile/Desktop
  if (!kIsWeb) return;

  // web.window accesses the JS global scope safely via Static Interop
  web.window.localStorage.setItem(key, value);
}

/// Manipulating a DOM element
void updateDOMInput(String selector, String newValue) {
  if (!kIsWeb) return;

  // 1. Query the DOM. Returns generic Element?
  final web.Element? element = web.document.querySelector(selector);

  // 2. Check if element exists
  if (element == null) return;

  // 3. Cast strictly to HTMLInputElement
  // In package:web, we verify the instance before assuming capabilities.
  if (element.instanceOfString('HTMLInputElement')) {
    final web.HTMLInputElement input = element as web.HTMLInputElement;
    input.value = newValue;
  }
}

/// Example of reading the current URL
String getCurrentUrl() {
  if (!kIsWeb) return '';
  return web.window.location.href;
}

3. Handling Events

dart:html provided convenient Dart Streams for events (e.g., element.onClick.listen(...)). package:web is lower-level and mirrors the standard JS EventTarget API.

Here is how to listen to a button click:

import 'package:web/web.dart' as web;
import 'dart:js_interop';

void attachClickListener(String selector, Function() onDartClick) {
  final element = web.document.querySelector(selector);
  
  if (element != null) {
    // We convert the Dart function to a JS Exported Dart Function
    // This allows the JS runtime to call back into the Dart Wasm runtime.
    element.addEventListener('click', (web.Event event) {
      onDartClick();
    }.toJS);
  }
}

Note the .toJS getter. This converts the Dart closure into a JavaScript-compatible function reference.

Why This Works: Extension Types

The magic behind package:web is Extension Types.

When you write web.HTMLInputElement in the new code, you aren't creating a Dart object that wraps a JS object (which costs memory and CPU). You are defining a zero-cost static view over the underlying JavaScript object.

// Simplified internal representation of how package:web works
extension type HTMLInputElement(JSObject _) implements HTMLElement {
  external String get value;
  external set value(String val);
}

When compiled to Wasm:

  1. The JSObject is an opaque reference ("externref") held by Wasm.
  2. Accessing .value generates a specific Wasm import call that asks the browser's JS engine for that property.
  3. There is no overhead of creating a Dart class hierarchy to shadow the DOM.

Conclusion

Migrating to package:web is not optional if you intend to leverage the performance benefits of WebAssembly in Flutter. While dart:html served us well for the JavaScript era, package:web aligns Flutter with the modern web standards of WasmGC and static interop.

To build your app with the new architecture, verify you have the latest Flutter beta or stable channel and run:

flutter build web --wasm

This will produce a build artifact that loads main.dart.wasm on supported browsers, falling back to JavaScript only where necessary.