Troubleshooting
This page is the index of “I saw X, what went wrong?” entries. Each one is a symptom, the cause behind it, and the fix. Most are referenced individually from concept and guide pages; this page is where you go when you do not know which page to look on.
If you have not yet started using the library, the Getting Started page and the concepts are the better entry points. This page assumes you have already hit something confusing.
For “why does it work this way” questions about design choices, see the FAQ.
Plugin lifecycle issues
Section titled “Plugin lifecycle issues”I get LateInitializationError reading service.pluginId or service.serviceId
Section titled “I get LateInitializationError reading service.pluginId or service.serviceId”Cause. Both fields on PluginService are declared late. They are stamped by the ServiceRegistry at resolution time, not at construction. If your test code constructs a service directly with MyService() and then reads service.pluginId before any resolve<T> call, you get LateInitializationError.
Fix. Construct the service through a ServiceRegistry so resolution actually runs. The simplest path is to register the service through its owning plugin and resolve from a test context. See the Testing guide: the “does this service compute correctly?” section covers pure-logic tests that do not need the stamped identity; the “does it interact with the bus and registry?” section uses SessionPluginContext.stub() plus a registered service so pluginId and serviceId are populated.
If your assertions never read pluginId or serviceId, the pure-logic path is enough. The moment they touch identity, resolve through a registry instead.
I get a StateError on runtime.init or runtime.createSession mentioning a missing factory
Section titled “I get a StateError on runtime.init or runtime.createSession mentioning a missing factory”Cause. You declared a custom global or session context type but did not pass the corresponding factory. The runtime cannot construct an arbitrary subtype of GlobalPluginContext or SessionPluginContext on its own, so when the type parameter is anything other than the default base, the factory is mandatory.
The exact errors look like:
StateError: globalContextFactory is required when using a custom global context type (MyGlobalContext). The default factory creates a GlobalPluginContext, which cannot be assigned to MyGlobalContext.StateError: contextFactory is required when using a custom session context type (MySessionContext). ...Fix. Pass the factory:
/// Demonstrates passing custom context factories to runtime.init and/// createSession.Future<void> initWithCustomContextFactories() async { final runtime = PluginRuntime<EditorGlobalContext, EditorSessionContext>( plugins: [AnalyticsPlugin()], ); runtime.init( globalContextFactory: (registry, bus, sessions) => EditorGlobalContext( registry: registry, bus: bus, sessions: sessions, application: EditorApplication(), flags: FeatureFlagClient(), ), );
final session = await runtime.createSession( contextFactory: (registry, sessionBus, globalBus) => EditorSessionContext( registry: registry, bus: sessionBus, globalBus: globalBus, document: const Document(title: 'untitled'), user: const UserSession(id: 'user-1'), ), );
print('session ready: ${session.context.document.title}'); await runtime.dispose();}See Custom Context for the full pattern, including how to keep your plugins typed against the custom context.
Two back-to-back plugin toggles throw StateError
Section titled “Two back-to-back plugin toggles throw StateError”Cause. updateSessionSettings rejects concurrent reconciliations. If a second toggle starts while the first one is still reconciling, the runtime throws StateError from PluginRuntime._enterReconcile(...).
Fix. Serialize the toggles so only one reconciliation runs at a time. The standard pattern is a tail-chained Future:
class TogglePendingExample { /// Pending toggle future, tail-chained to serialize back-to-back toggles. Future<void> togglePending = Future.value();
/// Serializes plugin enable/disable toggles to avoid race conditions. Future<void> setEnabled(PluginId pluginId, bool enabled) async { togglePending = togglePending.then((_) async { // compute next settings from the session's current state, // then updateSessionSettings, then refresh UI }); await togglePending; }}The code_editor example uses exactly this. If you expose toggles to users through any UI surface, do this or an equivalent serialization.
PluginLifecycleException was thrown; what do I do with it?
Section titled “PluginLifecycleException was thrown; what do I do with it?”Cause. One or more plugins threw during a lifecycle phase. The runtime collects errors across plugins (so plugin A’s failure does not abort plugin B’s attach) and throws an aggregate at the end of the phase.
Fix. Read phase and failures. The phase is one of:
attachGlobaldetachGlobalattachSessiondetachSessionupdateGlobalSettingsupdateSessionSettings
The failures field is a list of (PluginId, Object error, StackTrace stackTrace) records. Iterate and decide whether to log, reroute, or rethrow per plugin:
Future<void> safeInit() async { final runtime = PluginRuntime();
try { runtime.init(); } on PluginLifecycleException catch (e) { print('attach failed: ${e.phase}'); // Inspect e.failures for per-plugin error details. rethrow; }}The aggregation is intentional. Fail-fast on the first exception would hide downstream problems, so you fix one and immediately rediscover the next one. Plugin Kit surfaces the whole batch. See the Logging guide for the production pattern.
Why didn’t onPluginSettingsChanged fire when I called updateSettingsSnapshot?
Section titled “Why didn’t onPluginSettingsChanged fire when I called updateSettingsSnapshot?”Cause. updateSettingsSnapshot deliberately does not reconcile. It updates the runtime’s stored settings value and emits on settingsStream, but it does not run plugin lifecycle, does not call onPluginSettingsChanged, and does not push new config into already-resolved services. It is for the cases where you only want to publish a new snapshot to listeners (a debug panel, an analytics ping) without any actual lifecycle work.
Fix. Use updateSettings for the all-scopes path or updateGlobalSettings / updateSessionSettings for the lower-level per-scope paths when you want the runtime to converge on the new settings. Those run the full pipeline: detach plugins that lost enablement, attach newly enabled ones, fire onPluginSettingsChanged on plugins whose configuration changed, re-inject settings into resolved services.
If you wanted reconciliation and got silence, you almost certainly called the snapshot variant. See Settings and the Settings & Configuration reference.
My plugin compiled but never attached
Section titled “My plugin compiled but never attached”The most common cause is settings enablement. RuntimeSettings controls plugin enablement, but plugins without a RuntimeSettings.plugins entry are enabled by default unless they carry FeatureFlag.experimental. Plugins with FeatureFlag.experimental are disabled by default and require explicit opt-in. Plugins with FeatureFlag.locked are always enabled and cannot be turned off.
Build a RuntimeSettings with explicit entries for any stable plugin you want disabled and any experimental plugin you want enabled, then pass it to runtime.init.
The next most common cause is a dependency that is not enabled. The runtime auto-disables a plugin whose declared dependencies are missing, and logs an info entry. Look for an INFO entry mentioning your plugin id and the unmet dependency. Locked plugins are not auto-disabled, those dependency failures log at SEVERE.
Once the plugin is enabled and its dependencies are satisfied, check the lifecycle: did your attach handler actually run, and did you pass context to the Plugin helpers? See the first entry on this page.
runtime.init throws StateError saying a plugin or service id is unknown
Section titled “runtime.init throws StateError saying a plugin or service id is unknown”Cause. A RuntimeSettings you passed (often loaded from cached user storage) references a plugin id or service id that this runtime does not know about. Common when an app upgrade renames or removes a plugin: the cached settings written by the prior version still reference the old id, so the strict default surfaces the drift loudly.
The runtime validates three things at every init / createSession / updateSettings call:
- Plugin ids used in
RuntimeSettings.pluginskeys. - Plugin ids used in the plugin half of
RuntimeSettings.servicespin keys. - Service ids in plugin-scoped pins, checked after register-all (so a renamed slot on a still-existing plugin is caught too).
Pin.wildcard(...) pins are exempt from the service-id pass by design (they target whoever wins).
Fix. Pick the response that matches your environment:
runtime.init( // The default. Use during development and CI so typos and renamed // ids surface immediately. unknownReferencePolicy: UnknownReferencePolicy.throwError,);
runtime.init( // Recommended for production load paths that read cached settings // across app upgrades. Unknown entries are dropped and one severe // log entry per pass lists them; known entries still apply. unknownReferencePolicy: UnknownReferencePolicy.logAndSkip,);
runtime.init( // Silent drop. Use only when another channel (a settings UI, a // drift telemetry hook) already informs the user about the drop. unknownReferencePolicy: UnknownReferencePolicy.ignore,);The policy is set once on init and is read by every subsequent createSession / updateSessionSettings / updateGlobalSettings call on the same runtime. See PluginRuntime in the reference for the full constructor signature and the rest of the runtime-level parameters.
Service resolution issues
Section titled “Service resolution issues”registerFactory for a StatefulPluginService throws on registration
Section titled “registerFactory for a StatefulPluginService throws on registration”Cause. Factories construct a fresh instance on every resolve<T> call. The runtime tracks StatefulPluginService instances so it can call attach and detach on them at the right lifecycle moments. Factories produce orphan instances the runtime never sees again, which would leak subscriptions and skip cleanup. The registry refuses the registration outright:
ArgumentError: StatefulPluginService "<id>" must be registered as a singleton or lazy singleton, not a factory. They require proper lifecycle management which factories do not provide.Fix. Use registerSingleton (eager) or registerLazySingleton (constructed on first resolve) for any service that extends StatefulPluginService. Factory registration is fine for pure PluginService subclasses with no lifecycle hooks.
See the “Stateful services cannot be factories” Aside in Service Registry.
My service registers but won’t resolve, or resolves to the wrong one
Section titled “My service registers but won’t resolve, or resolves to the wrong one”Cause. Higher priority wins. Default is Priority.normal (500); another plugin at Priority.elevated will beat you.
Fix. Register at Priority.elevated (or Priority.above(other)) to win, Priority.low to be a fallback. registry.getRegistrations(serviceId) lists everything competing for the slot; the dialog’s Service Registry inspector shows it live.
Event bus and request/response
Section titled “Event bus and request/response”I throw inside an event handler and the bus seems to swallow it
Section titled “I throw inside an event handler and the bus seems to swallow it”Cause. It does not. The bus deliberately propagates handler exceptions to the caller of emit, request, or requestSync. If you are not seeing the exception, the most likely culprit is the call site: an unawaited emit, a Future whose error you never await, or a top-level runZonedGuarded that is eating it.
Fix. Make sure you are actually awaiting the bus call:
/// Wrong: handler throws but the error is on the unobserved Future.// context.bus.emit<UserMessage>(event: const UserMessage(text: '...'));
/// Right: caller sees the exception.Future<void> correctEmitUsage(PluginContext context) async { await context.bus.emit<UserMessage>(event: const UserMessage(text: 'hello'));}The bus does not aggregate errors the way PluginLifecycleException does; that aggregation is for plugin lifecycle only. Inside emit, the first throwing handler interrupts the cascade and the caller gets the exception. This is usually the right tradeoff: silent failure in an event pipeline is far harder to debug a month later than a loud, unambiguous exception at the call site. See the note in the Logging guide and the explicit semantics in Event Bus.
request<Req, Res> throws when no handler answers; I want null
Section titled “request<Req, Res> throws when no handler answers; I want null”Cause. request is the strict variant. It throws if no handlers are registered for the (Request, Response) type pair, and it throws if every registered handler returned null while Response is non-nullable. The thrown text starts with AllConcededException: <Request> -> <Response> ... every registered handler conceded with null but Response is non-nullable.
Fix. If “no handler” is a valid state in your domain, use maybeRequest (returns Future<Response?>) or maybeRequestSync (synchronous, also returns nullable). Or declare the response type as nullable so the null cascade is permitted:
/// Demonstrates maybeRequest returning null when no handler answers.////// `maybeRequest` is the canonical method when concession is a valid/// outcome: returns `null` for the no-answer case (no handler wired or/// every handler conceded) and propagates handler-thrown exceptions/// unchanged. Reach for `request` only when at least one handler is/// guaranteed to claim; the assertion fires loudly if it ever breaks.Future<void> demonstrateMaybeRequest(PluginContext context) async { context.bus.onRequest<SearchQuery, SearchResults>((env) async { if (env.event.query.isEmpty) return null; return SearchResults(results: ['result_${env.event.query}']); });
// Canonical: returns null when nobody answered. final result = await context.bus.maybeRequest<SearchQuery, SearchResults>( const SearchQuery(query: 'dart'), );
// Assertion variant: throws AllConcededException if every handler // conceded. Use only when at least one handler is guaranteed to claim. final result2 = await context.bus.request<SearchQuery, SearchResults>( const SearchQuery(query: 'dart'), ); print('$result $result2');}Pick one based on what reads better. maybeRequest is usually clearer at the call site. See Event Bus & Events reference.
Settings and reconciliation
Section titled “Settings and reconciliation”copyWith(priority: null) does not clear the priority on ServiceSettings
Section titled “copyWith(priority: null) does not clear the priority on ServiceSettings”Cause. The implementation uses the standard Dart ?? fallback:
ServiceSettings copyWith({ bool? enabled, Map<String, dynamic>? config, int? priority,}) { return ServiceSettings( enabled: enabled ?? this.enabled, config: config ?? this.config, priority: priority ?? this.priority, );}That pattern cannot tell null (clear it) from “argument not passed” (keep current). Both look the same to the function body.
Fix. When you actually want to clear the priority, construct the ServiceSettings directly rather than going through copyWith:
/// The correct way to clear a priority override on [ServiceSettings].////// [copyWith(priority: null)] keeps the existing priority because the/// implementation uses [??]. Construct a fresh instance to clear it.ServiceSettings clearPriority(ServiceSettings existing) { return ServiceSettings( enabled: existing.enabled, config: existing.config, // priority intentionally omitted, so it falls back to the default. );}This is the same quirk every copyWith pattern in Dart has. The library could add a sentinel object, but it does not, because the rebuild approach is unambiguous.
Tests, goldens, and CI
Section titled “Tests, goldens, and CI”Goldens render with block-rectangles instead of text
Section titled “Goldens render with block-rectangles instead of text”Cause. Flutter’s test environment ships with the Ahem font as the default fallback. Ahem renders every glyph as a solid rectangle. Useful for layout regression, useless for goldens that should look like actual UI in screenshots and docs.
Fix. Call loadAppFonts() from the golden_toolkit package once in setUpAll:
import 'package:golden_toolkit/golden_toolkit.dart';
void main() { setUpAll(() async { await loadAppFonts(); });
// tests ...}loadAppFonts registers every font declared in your app’s pubspec, plus the Roboto bundled with golden_toolkit, plus any platform fonts it can find. After it runs, goldens render with real glyphs.
If your widgets use fontFamily: 'monospace' (a system alias on most platforms, but undefined in tests), Ahem also wins. The dialog demo solves this by aliasing monospace to a bundled RobotoMono-Regular.ttf in its pubspec:
flutter: fonts: - family: monospace fonts: - asset: assets/fonts/RobotoMono-Regular.ttfNow the test asset bundle resolves the alias to a real font, and loadAppFonts picks it up. The full setup lives in example/plugin_kit_dialog_demo/test/golden_test.dart.