-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commands isolate
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.
spm isolate --output-dir ./isolated_widgets /path/to/project
spm isolate -o ./isolation-output -j map.jsonl /path/to/project| 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. |
Isolation combines static analysis with source transformation.
An AST visitor scans the target directories for rebuild boundaries:
- Classes that extend
State,ConsumerWidget, orHookConsumerWidget. - Anonymous builder functions passed to
BlocBuilder,BlocSelector,BlocConsumer,Consumer,Selector,Obx,GetX,GetBuilder, orObserver.
The TransplantExtractor converts a discovered scope into a new, self-contained StatefulWidget
(GeneratedWidget).
- For a
Stateclass, SPM also finds its companionStatefulWidgetto extract fields and constructors. - For a builder function, SPM extracts parameters such as
stateormodeland converts them into fields on the new state. -
contextis never lifted, sinceStatealready supplies one. A source class that declares its owncontextfield is skipped for the same reason: copying it across shadowsState.context.
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
analyzecounts 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 becomenull. - 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
ChangeNotifierlands on the stand-in that needs it.
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().
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>()andcontext.select<T, R>()are declared by provider or flutter_bloc, and10.spby 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 getcannot 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.
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
BlocBuildercallback sitting inside a method can read that method'sbool onlyNKN; the declaration stays behind when the callback moves. -
Members inherited from a package supertype.
controlleronGetViewfrompackage:getis 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.
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.
Isolated widgets often lack the original project's assets. The Skeletonizer:
- Identifies widgets like
Image.network,AssetImage, orSvgPicture. - Replaces them with lightweight placeholders.
- Keeps missing image files from preventing layout and rendering.
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.
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/
├── .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
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. |
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.
- 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 thefixture…and…Valueseeds 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, soanalyzereports a smaller tree there than it would against the full project. Repo-local widgets are unaffected, since those are extracted whole. - The
Skeletonizerhandles 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 carrysourceDependenciesResolved: false; runpub getin the source project and isolate again for comparable metrics.
Commands
Reference
Internals
Contributing