Skip to content

Commands isolate

albertoodev edited this page Aug 24, 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.
--[no-]inline-third-party Carry a third-party widget's own tree into the isolated file instead of standing it in. On by default.
--inline-max-declarations <n> How many third-party declarations one scope may carry. Defaults to 2000.
--inline-max-characters <n> How much third-party source, in characters, one scope may carry. Defaults to 2000000.
--[no-]prune-non-rebuild Leave out the code a rebuild cannot run: the body of a handler closure, and any member of the scope's class build() cannot reach. On by default.
--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.

2a. Code a rebuild cannot run

--prune-non-rebuild, on by default, leaves out the code a rebuild never executes. It is the rule analyze already applies before it counts anything (rule 8 under Extracted Features), asked here from the same predicate, so the isolated file and the row it is compared with agree on what a rebuild runs. Two things go:

  • The body of a closure in a non-rebuild slot. The closure keeps its signature, its async modifier and its place in the tree, and loses its body. An empty block replaces a body that returns nothing, and a throw replaces one that returns a value, because {} completes with null, which a String? validator tolerates and a Future<bool> confirmDismiss does not. The deferred hosts, then, addListener, addPostFrameCallback, scheduleMicrotask, Timer and Future.delayed, always take the empty block: those bodies do run, moments after the scope mounts, so throwing in one would trade an analyzer error for an uncaught exception around the first frame. An argument that is not a closure is untouched, since onTap: enabled ? _a : _b is evaluated while the tree is built.
  • A member of the scope's class that build() cannot reach. The reachable set is the closure TreeExtractor walks when it counts: the scope body, the helpers it calls, and the helpers those call. A member named only by a tear-off in a handler slot keeps its signature and loses its body, since the reference is evaluated while the tree is built and the name has to resolve. initState and didChangeDependencies are dropped like any other unreachable member, and what they seeded moves to the fixture block in step 5.

What this buys is the crawl it prevents. A handler body is where a scope pushes a route, calls a service and reaches a repository, so crawling it carried whole destination screens into the file and stood in for everything they called. A stand-in whose signature then failed to type-check cost the file outright, because analyze skips any file carrying an error-severity diagnostic.

erasedNonRebuildBodies counts the bodies emptied, and droppedUnreachableMembers names the members left out. The second is a list rather than a count because a member missing that should not be is the failure mode of the prune, and a count cannot say which one. --no-prune-non-rebuild carries everything as earlier versions did, and neither field then appears on a row.

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. Parameter types are dynamic, and so are return and field types unless the type reaches Widget, directly or as an iterable's element. Bodies hand back a value of the real type where one can be built and a _Stub where none can, so calling into a stand-in during build no longer crashes the frame.
  • A stand-in for a widget renders its child, children or body rather than swallowing it. The constructor used to accept the argument while the class built const SizedBox.shrink(), so whatever tree the transplanted code passed in was constructed and then never mounted, laid out or painted. children wraps in Stack, never Column: a Column with a non-literal children: pins treeListRenderingStrategy at its ceiling for every scope that reaches such a stand-in, in the isolated file and nowhere else.
  • A stand-in that is not a widget declares its nearest nameable supertype, which carries generic bounds a dynamic parameter cannot. Members that supertype already supplies are not redeclared beside it.
  • Third-party packages are never imported back into the isolated file, but a third-party widget is extracted the same way a repo-local one is, with its own tree and its companion State. A stand-in widget has an empty build, so a file full of them describes a tree the app never built and cannot be read or run as the scope it came from. Everything else a package declares, meaning value objects, controllers and services, still gets a stand-in that mirrors whether the original was a widget.
  • Third-party extraction is bounded, unlike the repo-local kind. A scope may carry 2000 third-party declarations or 2,000,000 characters, whichever it reaches first; past that the rest is stood in for and the row says so. Without a bound, a scope holding one BlocBuilder reaches a widget, and from there the crawl walks into the package's own machinery. The cap is a number to report rather than one to aim at: analyze walks a package library in place with no cap at all, so a scope that exhausts this one measures a smaller tree than the row it is meant to be compared with, and thirdPartyInlineTruncated is what lets such a row be excluded.
  • Extracting a package widget is now what makes the isolated row and the in-place row describe the same tree. Up to 0.6.0 it did the opposite: TreeExtractor asked AnalysisContextCollection.contextFor for the package's file, that throws for any path outside the analyzed roots, and the child was dropped along with its subtree, so a row that carried a package widget counted more than the same scope did in place. analyze reads a package library now, and the two sides are comparable across that boundary. Where they still are not, the row says so: thirdPartyInlineTruncated, thirdPartyInlineReverted, and the carriedUiDeclarations list, which the analyze row's walkedWidgetClasses is meant to be diffed against.
  • Extraction is also undone when it does not pay. After the output is verified, any scope that carried third-party source and still does not analyse is extracted a second time with the code stood in for instead, and whichever version has fewer errors is the one kept. A package widget generic over a type bounded by one of the package's own classes is the shape this exists for: carrying the widget brings its real bound with it, and the repo-local class that satisfies that bound in the app is a stand-in here with no supertype at all, so a file that type-checked against a stand-in's dynamic stops type-checking. A reverted row carries thirdPartyInlineReverted: true. The guarantee is exact rather than hopeful: no scope ends up with more errors than --no-inline-third-party would have given it.
  • A third-party declaration whose name package:flutter/material.dart also exports is carried under a mangled name, Card$spm for a package's own Card, and only the references that resolve to it are rewritten. A local declaration shadows the import either way, and an extracted one would otherwise put a body under every use of the name, including uses that meant Flutter's. Standing it in used to be the answer and it cost the subtree; renaming honours the same rule without that. renamedThirdPartyDeclarations lists the names, because the output no longer matches git show byte for byte at those points.
  • 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 whose name merely begins with flutter_ is third-party, so its code is carried or stood in for rather than imported, 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:some_package/some_package.dart'; not imported; whatever it declares that can build UI is carried as source, and the rest 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>() come from state-management packages, and 10.sp from 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 builder callback sitting inside a method can read that method's bool showArchived; the declaration stays behind when the callback moves.
  • Members inherited from a package supertype. A package base widget that hands its subclasses a controller getter 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.items inside an if (state is LoadedState) becomes (state as LoadedState).items.

5. Fixture seeding

The isolated scope is no longer called with arguments, so every value its build depends on is declared in one region of the file. Three kinds of binding end up there:

  • a field lifted in step 4, which never had a value here;
  • a field the scope declared with its own value. int _limit = 20; becomes late int _limit; on the generated State and int fixtureLimit = 20; at the top level. The value is relocated, never replaced, so a list seeded with twenty rows still builds twenty;
  • a field left unseeded because the member that assigned it was dropped by the prune, which is the usual fate of a late final the original initState filled in.

SPM generates an initState that assigns each one from a conventionally named symbol:

Binding Assignment generated
field items items = fixtureItems;
field _controller _controller = fixtureController;
cross-file project global application application = applicationValue;

Globals are assigned first, since a field initialiser may read one. Under --no-prune-non-rebuild the transplanted class can still bring its own initState, and it 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 in one delimited block at the bottom of the same file, with a value of its own type:

// Fixture block. These are the bindings the transplanted scope used to receive
// from the application, and this is the one region to edit when a real value is
// needed: nothing outside it has to change. Collections are generated empty, so
// a scope whose rows come from a lifted list builds none until one is filled in.
late FilterState fixtureFilter;
bool fixtureOnlyActive = false;
String fixtureHeading = '';

A field stays where it was declared in three cases, and every reason is about not moving a feature. A static or const field, because treeConstWidgetCount and rootBuildReturnsConstWidget are features, and a static const read inside a const constructor stops that call being const the moment it becomes a variable. A field whose initialiser needs the instance, because it cannot be evaluated at the top level. And a field with no nameable type, because the fixture declaration has to write the type down.

final is dropped from a hoisted binding. A member the prune keeps may still assign the field, and late final would make that a second write to a final.

These used to be late with no value, on the argument that a fabricated default could be mistaken for the value that was really there. The argument holds for types and not for values: the generated initState reads every one of them, so an unassigned late is a LateInitializationError before the first frame, and a transplant that will not run cannot be measured at all. A value is never measured, because the features come from the shape of the build tree and that shape is fixed before any of this executes.

Where no value of a binding's type can be built, the late form stays and the name is listed in unseededBindings on the row, so a file that still cannot mount says which binding stopped it.

The block is the one region to edit. Replacing a value there makes the widget behave as the original did, and nothing outside it has to change.

5a. A constructor a benchmark can call

The transplant copies the original StatefulWidget's constructor verbatim and renames it, so a scope whose widget declared required this.arguments emits a GeneratedWidget nothing can construct without knowing that scope's field names and types. GeneratedWidget.fixture() is emitted beside it, defaulting every field through the same helper:

const GeneratedWidget({Key? key, required this.arguments}) : super(key: key);

GeneratedWidget.fixture({super.key}) : arguments = ChatPageArguments(peerId: '');

The copied constructor stays, because it is part of the commit's source. fixtureConstructor on the row says whether the fixture one was emitted; it is false when a field's type could not be built, and then mounting that scope means supplying the value first.

6. Visual transformation (skeletonization)

Isolated widgets lack the original project's assets and have no network to reach, so the Skeletonizer substitutes the image source and keeps everything else:

Construction Becomes
AssetImage, NetworkImage, FileImage, MemoryImage const AssetImage('assets/placeholder.png'), still an ImageProvider
Image.network, Image.file, Image.memory, Image.asset Image.asset('assets/placeholder.png', …), every other argument kept
DecorationImage, RawImage kept in place; their image argument is substituted

Replacing the whole construction, which is what this used to do, cost three things. It erased the widget subtrees inside errorBuilder, which analyze walks and counts in place. It turned an ImageProvider into a widget, so the same source moved out of valueObjectAllocCount into treeNonConstWidgetCount and gained a level of depth. And it put a widget in a provider-typed slot, which BoxDecoration(image:) and CircleAvatar(backgroundImage:) reject, manufacturing the commonest error in the output.

loadingBuilder has no home on Image.asset and is the one argument that still drops. The file carries a marker comment where it stood, and droppedLoadingBuilders counts them.

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.
inlinedThirdPartyDeclarations How many third-party declarations were carried into the file as source. Omitted when none were.
thirdPartyInlineTruncated Present and true only when the inline budget ran out, so third-party UI this scope reaches was stood in for. Such a row measures a smaller tree than the same scope would in place.
thirdPartyInlineReverted Present and true only when carrying the third-party code left the file with more errors than standing it in did, so the stood-in version was kept. Such a row measures a smaller tree than the same scope would in place, so it is one to exclude rather than to compare.
unseededBindings Bindings the fixture block could build no value for, by name. Reading one throws before the first frame. Omitted when there are none.
fixtureConstructor Whether GeneratedWidget.fixture() was emitted. false means a field's type could not be built, so mounting the scope needs that value supplied first.
droppedLoadingBuilders How many loadingBuilder arguments the image rewrite had to drop. Omitted when none were.
carriedUiDeclarations The UI-producing declarations carried into the file, as libraryUri#Name. Diff it against the analyze row's walkedWidgetClasses: for every declaration analyze walks in place, isolate has to carry the source, or the two rows describe different trees. Omitted when there are none.
renamedThirdPartyDeclarations Third-party names carried under a mangled name because package:flutter/material.dart exports the same one. Omitted when there are none.
erasedNonRebuildBodies How many closure bodies the prune emptied. Omitted when none were, which is every row under --no-prune-non-rebuild.
droppedUnreachableMembers The members of the scope's class build() could not reach, by name. Named rather than counted: a member missing that should not be is how the prune fails, and a count cannot say which one. Omitted when there are none.

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 and now also run: a body hands back a value of the real type where one can be built and a stub where none can, and the fixture block carries values rather than throwing. What a stub cannot do is occupy a typed slot the call site chose, and it iterates over nothing, so a scope whose rows come from a lifted list builds none of them. Read the written file rather than assuming which scopes this still holds for.
  • A stand-in for a widget has an empty build, so its own subtree is not part of the isolated file. For a repo-local widget that means analyze reports a smaller tree there than it would against the full project; for a third-party one the in-place run could not read the subtree either, so the isolated row is the larger of the two. Widgets are extracted whole, repo-local and third-party alike, so this is now the exception rather than the rule. It is reached four ways, and the first two are visible in the row: the inline budget ran out (thirdPartyInlineTruncated), carrying the code analysed worse than standing it in (thirdPartyInlineReverted), the run was given --no-inline-third-party, or the declaration's name is one package:flutter/material.dart also exports.
  • --no-inline-third-party reproduces the output of SPM 0.5.2 and earlier, where every third-party symbol was stood in for. It runs faster and writes smaller files, and every scope using a third-party widget measures smaller than it does in place. Use it to compare against older results, not to produce new ones.
  • The Skeletonizer substitutes the source of Flutter's own image constructions, because an isolated file has no assets directory and no network. Image widgets from packages are not touched: they are third-party widgets and are carried like any other. assets/placeholder.png is in no bundle, so anything that runs an isolated file has to answer for that key. Custom fonts or localized strings may still need manual setup if critical to the widget.
  • An isolated file is not the scope's source. It holds what a rebuild runs, so a handler body is a signature with nothing in it and a member build() cannot reach is a name or is gone. Read the file to see what a rebuild costs, never to see what the widget does when it is used. --no-prune-non-rebuild gives back the fuller file, at the cost of the crawl that file drags in.
  • 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