Skip to content

Commands isolate

albertoodev edited this page Aug 21, 2026 · 7 revisions

isolate: targeted profiling

isolate extracts rebuild scopes such as a Flutter State class or a BlocBuilder callback into standalone Dart files. The smaller files make it possible to profile one UI segment without running the rest of the application.

Usage

spm isolate --output-dir ./isolated_widgets /path/to/project
spm isolate -o ./isolation-output -j map.jsonl /path/to/project

Options and flags

Flag / Option Short Description
--output-dir <path> -o Required. Directory where the isolated files are saved.
--jsonl <path> -j Output JSONL mapping original source paths → isolated file paths.
--verbose -v Enable verbose logging of the isolation process.
<dir> [dir…] One or more directories to scan for rebuild scopes.

How it works

Isolation combines static analysis with source transformation.

1. Discovery

An AST visitor scans the target directories for rebuild boundaries:

  • Classes that extend State, ConsumerWidget, or HookConsumerWidget.
  • Anonymous builder functions passed to BlocBuilder, BlocSelector, BlocConsumer, Consumer, Selector, Obx, GetX, GetBuilder, or Observer.

2. Normalization and transplantation

The TransplantExtractor converts a discovered scope into a new, self-contained StatefulWidget (GeneratedWidget).

  • For a State class, SPM also finds its companion StatefulWidget to extract fields and constructors.
  • For a builder function, SPM extracts parameters such as state or model and converts them into fields on the new state.
  • context is never lifted, since State already supplies one. A source class that declares its own context field is skipped for the same reason: copying it across shadows State.context.

3. Transitive dependency resolution

To keep the generated file compilable, SPM recursively crawls the code for every referenced symbol:

  • Methods, fields, and getters used within the same class or file move into the generated file.
  • A custom widget, enum, or painter from another file is extracted along with its own dependencies, including the base class it extends. A widget whose supertype went missing would stop reading as a widget, which changes what analyze counts rather than merely failing to compile.
  • A class that is not a widget but hands one out, such as a theme helper with a Widget buildDivider(), is also extracted whole. Its body is part of the tree the scope builds.
  • Business logic, models, services and constants are not copied. They are replaced by a declaration-only stand-in: the name, the members the scope actually reaches, and nothing else. Bodies become throw UnimplementedError() and constants become null.
  • Third-party packages are never imported back into the isolated file; their symbols get the same stand-in treatment. A stand-in mirrors whether the original was a widget, so a third-party widget still reads as one and a value object still reads as a value object.
  • A stand-in carries every member of a type that declares 40 members or fewer, and only the members the scope reaches for anything larger. Members are recorded against the type the code names, not the type that declares them, so a member inherited from a Flutter base class such as ChangeNotifier lands on the stand-in that needs it.

3a. Imports

Only dart: libraries and package:flutter/ itself are imported. The trailing slash is the whole test: a pub package named flutter_bloc or flutter_scale_kit is third-party and gets stand-ins, not an import, because the isolated file has to resolve against a package that depends on flutter and nothing else.

An import keeps what the source wrote it with:

Source directive Isolated file
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:convert' show jsonEncode; import 'dart:convert' show jsonEncode;
import 'package:flutter_bloc/flutter_bloc.dart'; not imported; BlocBuilder becomes a stand-in
import 'dart:convert' deferred as c; import 'dart:convert' as c;

The prefix matters because the transplanted body is copied verbatim: a scope that writes math.pi only resolves if as math comes with it. Prefixes are collected from every file a transplant copied code from, not only the file the scope was found in. deferred is dropped, since the generated build never calls loadLibrary().

3b. References that resolve to nothing

Some references have no element to work from at all. Two situations produce them:

  • An extension defined in a package the isolated file may not import. context.read<T>(), context.watch<T>() and context.select<T, R>() are declared by provider or flutter_bloc, and 10.sp by a sizing package. The member is real, but its definition is behind an import the gate refuses.
  • A source project whose dependencies were never installed. If pub get cannot run, the analyzer resolves nothing outside the project, so every third-party name in it is unresolved.

SPM rebuilds a stand-in for these from the call sites themselves:

extension _SpmBuildContextShim on BuildContext {
  dynamic read<T0>() => throw UnimplementedError();
}

extension _SpmNumShim on num {
  dynamic get sp => throw UnimplementedError();
}

class MissingCard extends StatelessWidget {
  const MissingCard({dynamic title, dynamic count});
  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

late dynamic missingGlobal;

Everything reconstructed this way is typed dynamic, because syntax is all there was to read. The exception is widget-ness: an unresolved constructor call in a widget position, such as the value of a child: argument, is stood in for by a widget, so analyze still counts the allocation as one.

A project in the second situation is reported rather than passed over. Every mapping row produced from it carries sourceDependenciesResolved: false, and the run logs the directory.

4. Captured bindings

A scope also reads names it does not declare, and those do not travel with the transplanted source. Two kinds are lifted onto the generated State:

  • Locals and parameters of the enclosing method. A BlocBuilder callback sitting inside a method can read that method's bool onlyNKN; the declaration stays behind when the callback moves.
  • Members inherited from a package supertype. controller on GetView from package:get is the recurring case. The transplant never emits such a class, so the member becomes a field.

Names declared inside the scope, members of the enclosing class, and top-level declarations are left alone: the first travel with the source and the other two are already handled by steps 2 and 3.

Lifting a parameter to a field costs it type promotion, which Dart applies to locals but not to fields. Uses whose promoted type was narrower than the declared one are rewritten with an explicit cast, so state.wallets inside an if (state is WalletLoaded) becomes (state as WalletLoaded).wallets.

5. Fixture seeding

Lifted fields have no values, because the isolated scope is no longer called with arguments. SPM generates an initState that assigns each one from a conventionally named symbol:

Lifted binding Assignment generated
field wallets wallets = fixtureWallets;
field _controller _controller = fixtureController;
cross-file project global application application = applicationValue;

Globals are assigned first, since a field initialiser may read one. A scope whose transplanted class already brought its own initState keeps it: overwriting a real one would discard setup the scope depends on, so its lifted fields must be assigned by hand. The symbols below are still declared; only the assignments are missing.

Each of those symbols is declared at the bottom of the same file, as a late variable with no value:

late WalletState fixtureWallets;

Assign one before the widget builds and it behaves as the original did. Leave it and reading it throws, which is deliberate: a fabricated default would be measured as though it were the value that was really there. Declaring them keeps the file analyzable, because spm analyze skips any file carrying an error-severity diagnostic, and an undefined name is one.

6. Visual transformation (skeletonization)

Isolated widgets often lack the original project's assets. The Skeletonizer:

  • Identifies widgets like Image.network, AssetImage, or SvgPicture.
  • Replaces them with lightweight placeholders.
  • Keeps missing image files from preventing layout and rendering.

7. Formatting

After the files are written, SPM runs dart format over the output directory. The transplant concatenates fragments that keep their original indentation, so unformatted output differs between runs in layout as well as in code. Formatting is best-effort: a scope that produced unparseable Dart is still written out for inspection.

8. Verification

SPM then analyses the files it wrote, in the same process and through the same analyzer the extraction used. This is not optional, because the number that matters is not how many scopes were written but how many of them analyze can read: analyze skips any file carrying an error-severity diagnostic, so a scope that was written and does not analyse contributes nothing.

The run prints the result:

[spm]: Successfully isolated 15 scopes into ./isolation-output
[spm]: 15 of 15 analyse clean (0 errors in total).

To make the isolated files resolvable where they now sit, the output directory gets a pubspec.yaml and a .dart_tool/package_config.json copied from the source project with its package roots made absolute. Both are left alone if they already exist.

The only edit this step makes is removing an import nothing uses. An undefined name is reported and never invented here: a stand-in has to be built where its widget-ness is known, which is steps 3 and 3b, not after the fact.

If no package config can be found for the source project, nothing is analysed and every row reports verified: false. A run that cannot resolve package:flutter would call every import broken, and an honest gap is better than a fabricated verdict.

Output structure

output/
├── .dart_tool/
│   └── package_config.json   # copied from the source project, for verification
├── pubspec.yaml              # so the isolated files resolve where they sit
├── State/
│   ├── login_form_state.dart
│   └── product_list_state.dart
├── BlocBuilder/
│   └── user_profile_builder.dart
└── mapping.jsonl

Mapping file (mapping.jsonl)

Links each isolated file back to its original source. One JSON object per line:

{
  "originalPath": "/abs/path/to/project/lib/ui/login_form.dart",
  "nodeType": "State",
  "isolatedPath": "/abs/path/to/output/State/login_form_state.dart",
  "name": "LoginFormState",
  "verified": true,
  "errorCount": 0,
  "warningCount": 0
}
Field Description
originalPath Absolute path of the source file the scope was extracted from.
nodeType Scope kind, which is also the output subdirectory name, such as State, BlocBuilder, or Consumer.
isolatedPath Absolute path of the generated standalone file.
name Name of the extracted class or builder-owning widget.
verified Whether the file was analysed after being written. false means the run had no package config to resolve package:flutter with, not that the file failed.
errorCount Error-severity diagnostics in the isolated file. Present only when verified is true. A row with anything other than 0 here is a row analyze will skip.
warningCount Warning-severity diagnostics. Present only when verified is true.
topCodes The most frequent error codes in the file, most frequent first, at most five. Omitted when there are none.
unresolvedImports URIs the file imports that do not exist. Omitted when there are none.
unresolvedNames Names the file uses that nothing declares. Omitted when there are none.
sourceDependenciesResolved Present and false only when the source project's dependencies could not be resolved, so every third-party reference in it was rebuilt from syntax.

When isolation fits

Use an isolated scope when a whole application adds unrelated work to frame timings or when a benchmark needs direct control of the values passed into one builder. The generated GeneratedWidget can be placed in a benchmark loop and supplied with controlled inputs.

Limitations

  • The stand-ins that replace non-UI and third-party dependencies resolve, but they do not run: every body throws and every constant is null. An isolated file is meant to be analyzed as it stands; running one means assigning the fixture… and …Value seeds and replacing whichever stand-ins the scope actually calls into.
  • A stand-in for a third-party widget has an empty build. Its own subtree is not part of the isolated file, so analyze reports a smaller tree there than it would against the full project. Repo-local widgets are unaffected, since those are extracted whole.
  • The Skeletonizer handles images, but custom fonts or localized strings may still need manual setup if critical to the widget.
  • A scope taken from a project whose dependencies do not resolve is reconstructed from syntax alone. Types that would have been mirrored from the real element become dynamic, and a stand-in that cannot be told apart from a widget by its position in the source is not treated as one. Rows from such a project carry sourceDependenciesResolved: false; run pub get in the source project and isolate again for comparable metrics.

Clone this wiki locally