Skip to content
Back to skills

Flutter Web

ASecurity

Use when working on a Flutter web build - code touching dart:js_interop, package:web or dart:ui_web; tests that will not compile on the VM runner; a blank white page after a build; or QA of a running web app in a browser. Triggers on "works on mobile but not on web", a suite that breaks after a web import is added, or a screen that looks frozen in the browser.

  • 11 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 19, 2026
developmentrustbashtestingdebuggingapiperformance

Works with

  • cli
  • api

Security analysis

A93/100
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned September 24, 2026

npx -y skills add gabuldev/flutter-skills --skill flutter-web --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Flutter Web?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Flutter Web
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gabuldev-flutter-web/badge)](https://www.skillsdirectory.com/skills/gabuldev-flutter-web)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: flutter-web
description: Use when working on a Flutter web build - code touching dart:js_interop, package:web or dart:ui_web; tests that will not compile on the VM runner; a blank white page after a build; or QA of a running web app in a browser. Triggers on "works on mobile but not on web", a suite that breaks after a web import is added, or a screen that looks frozen in the browser.
---

You are working on a Flutter **web** build. Everything here is a lesson that
cost real hours — each one looks like an application bug and is not.

## Browser QA only counts in a visible tab

Flutter web drives its **entire** frame pipeline from `requestAnimationFrame`,
and Chrome throttles rAF to near zero in a tab that is not the active one. In a
background tab the app still runs — events are processed, network calls fire —
but it barely paints. So route transitions appear frozen for tens of seconds,
and click coordinates read off a screenshot land nowhere, because the painted
frame no longer matches the real hit-test layout.

That looks exactly like two bugs that do not exist: a slow splash screen and
broken Tab traversal.

Before blaming the app:

```js
document.visibilityState   // must be "visible"
```

If it reports `hidden`, bring the browser window forward and make the tab
active. Two things stay reliable either way, and are the better evidence:

- `performance.getEntriesByType('resource')` for real timings — in one case it
  showed auth resolving at 1.4s while the screen looked stuck for 20s.
- `document.activeElement`, and the hidden `<input>` Flutter creates for the
  focused text field, which proves where focus actually is.

Each CDP screenshot forces one frame, so consecutive screenshots step a stalled
transition forward — useful, and itself a hint that you are in a throttled tab.

## Browser-layer code needs the Chrome test runner

Anything touching `dart:js_interop` or `package:web` — `getUserMedia`,
`MediaRecorder`, an `<audio>` element — **cannot even compile** under the
default VM test runner.

`@TestOn('browser')` is what makes that survivable: `flutter test` *skips* an
annotated file instead of compiling it. The browser layer is untested by
default, not untestable:

```bash
flutter test                      # VM: controllers, repositories, pure widgets
flutter test --platform chrome    # the browser layer
```

**Both commands run over `test/`.** Do not move browser tests to a directory of
their own — the web runner builds its entrypoint with `test/` as the root, so a
path outside it becomes a `../` that does not resolve and nothing compiles at
all. The annotation is the separator, not the directory.

The trap that makes this dangerous: `flutter test` alone reports **green** over
a browser test that never compiled. Run both before pushing, and run both in
CI as two separate steps.

`--platform chrome` is also flaky or unavailable on some machines (a file with
`@TestOn('browser')` hanging at *loading* until the test timeout is the usual
symptom). Where that happens, CI is the only place the browser layer runs — so
prefer **pulling the logic out of the browser-bound file into a plain Dart
one** and testing that on the VM runner.

### Make it testable: contract first, js_interop behind it

Declare the **minimum contract as an abstract class**, keep the `js_interop`
implementation behind it, and inject it so a widget test can pass a fake:

```dart
abstract class AudioRecorder {
  Future<void> start();
  Future<Uint8List?> stop();
  void dispose();
}

// browser_audio_recorder.dart — the only file importing package:web
class BrowserAudioRecorder implements AudioRecorder { ... }
```

### Every exit path must release a browser resource

When a widget holds a browser resource, release it on **every** way out —
not only the happy one:

- `dispose()` unconditionally, rather than only when a `_recording` flag flipped.
- A release when the screen turns out to be unmounted after an `await`.
- A guard so a second tap during the first `await` is a no-op.

A real leak was exactly the gap between those: permission was still pending, so
the flag had not flipped, so `dispose()` released nothing — and an orphaned
ticker wrote to a closed `StreamController` every 200 ms for the life of the tab.

## Blank white page after changing a path dependency

Path dependencies (a `design_system/` or `calendar/` package in the same repo)
are not always picked up by the incremental web build. It emits a bundle that
compiles, loads every asset, throws no JS error — and renders a blank white page
with an empty `<flutter-view>`. Hours disappear looking for a bug in code that
is fine.

```bash
rm -rf build/web && flutter build web --release --dart-define=BASE_URL=...
```

Do that **before** concluding anything from a blank screen.

## Watch the import chain, not just the import

A web-only import poisons every file that reaches it transitively, and the
compiler names the file that failed — not the file at fault.

The classic shape: a `app_routes.dart` holding both the route-name constants
*and* the route table. The table imports every screen, one of which imports
`dart:ui_web`. A sign-out use case importing that file for **one string
constant** then makes every VM test that touches it fail to compile, taking the
whole suite with it.

The fix is to split the constants into a file that imports nothing:

```dart
// app_route_names.dart — imports nothing
class AppRouteNames {
  static const login = '/login';
  static const home  = '/home';
}

// app_routes.dart — re-exports the names, and owns the table
export 'app_route_names.dart';

class AppRoutes { /* onGenerateRoute + the table */ }
```

Re-export at the **library** level, with `export`. Do not try to inherit the
names (`class AppRoutes extends AppRouteNames`): Dart does not inherit static
members, so `AppRoutes.login` would not resolve even when it compiles — and if
the names class is `final`, extending it is a compile error outright. Callers
say `AppRouteNames.login`; the `export` is what saves them a second import.

Screens and the root widget may import the table. A use case, a repository or
an interceptor may not — they take the names file.

So when the VM suite fails to **load** a file rather than fail an assertion,
read which file it named, then walk the import chain back to the web-only leaf.

## Checklist before shipping a web change

```bash
flutter analyze
flutter test                              # VM
flutter test --platform chrome            # browser layer
rm -rf build/web
flutter build web --release --dart-define=BASE_URL=<api-url>
```

## Common mistakes

- Debugging a "frozen" screen in a background tab. Check `document.visibilityState` first.
- Trusting a green `flutter test` over code that only compiles on the browser runner.
- Moving browser tests out of `test/` — `--platform chrome` cannot resolve a path outside it.
- Calling `js_interop` straight from a widget instead of behind an injectable interface.
- Releasing a browser resource only on the happy path. Unmount during an `await` is a real exit.
- Concluding "blank page = my bug" without a clean `build/web` rebuild first.
- Importing the route *table* from a use case or interceptor just to read one route name.
- Forgetting `--dart-define=BASE_URL`, so the compile-time constant is `""` and every call fails. See **flutter-run**.
- Leaving semantics off — Flutter web disables them by default. See **flutter-accessibility**.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.