Skip to content

Custom Contexts

The default PluginContext, GlobalPluginContext, and SessionPluginContext give you a registry, a bus, and some framework-level plumbing. That is enough for small apps. Once your domain has opinions, you usually want the context to carry them directly instead of resolving the same services over and over.

Custom contexts are how you do that.

Reach for this after the plugin shape is already clear. Contexts are for shared domain facts that many plugins need: the current document, current user, tool registry, app shell, or routing table.

Use the default contexts if plugins only ever interact with the registry and the bus. If your plugins keep reaching for the same piece of state (the current user, the active document, a tool registry, a routing table), promoting it to a field on a custom context makes every plugin simpler.

Rough rule: if three plugins want the same thing, put it on the context.

Subclass SessionPluginContext (or GlobalPluginContext, or both) to add fields.

class EditorSessionContext extends SessionPluginContext {
/// The document open in this session.
final Document document;
/// The user who owns this session.
final UserSession user;
/// Creates an [EditorSessionContext].
EditorSessionContext({
required super.registry,
required super.bus,
required super.globalBus,
super.extras,
required this.document,
required this.user,
});
@override
EditorSessionContext copyWith({
ServiceRegistry? registry,
Map<String, Object>? extras,
EventBus? bus,
EventBus? globalBus,
}) {
return EditorSessionContext(
registry: registry ?? this.registry.copy(),
bus: bus ?? this.bus,
globalBus: globalBus ?? this.globalBus,
extras: extras ?? this.extras,
document: document,
user: user,
);
}
}

Session plugins can now type their context directly:

class AutosavePlugin extends SessionPlugin<EditorSessionContext> {
@override
PluginId get pluginId => const PluginId('autosave');
@override
void attach(EditorSessionContext context) {
on<DocumentEdited>(context, (_) {
scheduleSave(context.document, context.user.id);
});
}
}

No casting, no re-resolving. context.document and context.user are always there.

Every subclass MUST override copyWith() to return its own type. The framework calls copyWith() on the live context to snapshot pre-reconcile state and pass it to Plugin.onPluginSettingsChanged(oldContext, newContext) as oldContext. Without an override, virtual dispatch falls through to the parent’s copyWith, which constructs the parent type. Any plugin that overrides onPluginSettingsChanged with a covariant subtype will then throw TypeError at runtime when oldContext arrives as a base-type instance.

class EditorSessionContext extends SessionPluginContext {
final Document document;
final User user;
EditorSessionContext({
required this.document,
required this.user,
required super.registry,
required super.bus,
required super.globalBus,
super.extras,
});
@override
EditorSessionContext copyWith({
ServiceRegistry? registry,
Map<String, Object>? extras,
EventBus? bus,
EventBus? globalBus,
Document? document,
User? user,
}) {
return EditorSessionContext(
document: document ?? this.document,
user: user ?? this.user,
registry: registry ?? this.registry.copy(),
bus: bus ?? this.bus,
globalBus: globalBus ?? this.globalBus,
extras: extras ?? this.extras,
);
}
}

The annotation @mustBeOverridden on the parent’s copyWith() causes the analyzer to warn if you forget. The runtime additionally asserts oldContext.runtimeType == liveContext.runtimeType during reconciliation in debug mode, so a missed override surfaces immediately in tests rather than at the first covariant onPluginSettingsChanged call.

The runtime does not know your custom context type unless you hand it a factory.

ScopeWhere to supply the factory
Globalruntime.init(globalContextFactory: ...)
Sessionruntime.createSession(contextFactory: ...)
final session = await runtime.createSession(
contextFactory: (registry, sessionBus, globalBus) {
return SessionPluginContext(
registry: registry,
bus: sessionBus,
globalBus: globalBus,
extras: {'test': 'value'},
);
},
);

A global context usually carries application-level singletons that do not belong in the registry: the top-level app object, a telemetry client, a feature flag service.

class EditorGlobalContext extends GlobalPluginContext {
/// The running editor application.
final EditorApplication application;
/// The feature flag client for runtime toggles.
final FeatureFlagClient flags;
/// Creates an [EditorGlobalContext].
EditorGlobalContext({
required super.registry,
required super.bus,
required super.sessions,
super.extras,
required this.application,
required this.flags,
});
@override
EditorGlobalContext copyWith({
ServiceRegistry? registry,
Map<String, Object>? extras,
EventBus? bus,
List<PluginSession<SessionPluginContext>>? sessions,
}) {
return EditorGlobalContext(
registry: registry ?? this.registry.copy(),
bus: bus ?? this.bus,
sessions: sessions ?? this.sessions,
extras: extras ?? this.extras,
application: application,
flags: flags,
);
}
}

Global plugins consume it exactly like session plugins consume the session context.

class AnalyticsPlugin extends GlobalPlugin<EditorGlobalContext> {
@override
PluginId get pluginId => const PluginId('analytics');
@override
void attach(EditorGlobalContext context) {
if (context.flags.isOn('analytics_v2')) {
context.application.telemetry.start();
}
}
}

A session plugin can declare a custom session context while the global context stays the default, and vice versa. The two are independent. A plugin that extends SessionPlugin<EditorSessionContext> is happy as long as its session’s factory produces that type. Whatever the global scope looks like is not its concern.

PluginRuntime exposes both factories directly.

late PluginRuntime runtime;

No subclassing needed.

Two small cautions.

Do not put everything on the context. If only one plugin uses a field, that field belongs in that plugin’s services, not on the context. The context is a shared scratchpad for things multiple plugins want.

Do not mirror the registry. If a field on the context is already resolvable from the registry, you are probably duplicating information that will drift. Pick one home.

Most teams converge on a small, stable custom context with three to five fields. Anything past that usually wants to be a service, a capability, or a session-scoped service.