UI Plugins
The Cosmos UI plugin system consists of an intricate web of "slots" and "plugs" that weave together with minimal knowledge of each other.
Though it will take some time to get familiar with the subtleties of the Cosmos UI plugin architecture, its core principles are simple and so is creating your first “Hello World”.
The underlying react-plugin
library isn't published yet due to time
constraints but will be open sourced as a separate project in the future.
Boilerplate
The ui
field in cosmos.plugin.json
points to a module like this:
import React from 'react';
import { createPlugin } from 'react-plugin';
const plugin = createPlugin({ name: 'magicPlugin' });
// We're plugging a React component into an existing slot called "coolSlot"
plugin.plug('coolSlot', () => {
return <div>This is magic.</div>;
});
plugin.register();
Plugin API
Slots and plugs make up for the render composition but there's more to UI plugins. To function as standalone UI abstractions that can interact in meaningful ways, UI plugins can also have individual configuration, private states, public methods and event handlers.
createPlugin
These are the arguments supported when creating a plugin:
Argument | Description |
---|---|
name | UI plugin identifier. |
defaultConfig | Optional plain object config. Set via Cosmos config under ui.{pluginName} and read privately via PluginContext.getConfig . |
initialState | Optional plain object state. Accessed privately via PluginContext.getState and PluginContext.setState . |
methods | Optional method handlers called by other plugins via PluginContext.getMethodsOf . |
Once created, the plugin API allows registering UI plugs, as well as onLoad
and event handlers.
Plugin.plug
Plug a React component into a <Slot>
:
plugin.plug('slotName', () => {
return <MyComponent />;
});
Plugs get access to the PluginContext
:
plugin.plug('slotName', ({ pluginContext }) => {
return <MyComponent state={pluginContext.getState()} />;
});
Plugs can receive slot props, which allows slots to parameterize their plugs:
plugin.plug('slotName', ({ slotProps }) => {
return <MyComponent name={slotProps.name} />;
});
Somewhere in another plugin...
<Slot name="slotName" slotProps={{ name: 'Sara' }} />
Plugs can receive children
from their slot. This allows plugs to act as decorators:
plugin.plug('slotName', ({ children }) => {
return <MyDecorator>{children}</MyDecorator>;
});
Additionally, when multiple plugs are registered for the same slot they can compose each other. The first plug will be children
for the second plug, the first plug wrapped in the second will be children
for the third plug, and so on.
You can also choose not to render children
in a plug, thereby ignoring the slot's children and replacing all previous plugs.
Plugin.namedPlug
Plug a React component into an <ArraySlot>
:
plugin.namedPlug('slotName', 'plugName', () => {
return <MyComponent />;
});
Contrary to Plugin.plug
where a plug decorates or replaces previous plugs, a named plug is appended to a list for the slot to render. Multiple named plugs are allowed for the same slot and all will be rendered independently.
Named plugs are rendered in the order they are registered, or based on the
optional plugOrder
prop of the ArraySlot
component.
Plugin.onLoad
Register a handler that gets called once when the Cosmos UI loads, or when the plugin is enabled. If the handler returns a callback it will be called when the plugin is disabled or uninstalled.
plugin.onLoad(pluginContext => {
// Add DOM event or fetch external data
return () => {
// Optional: Clean up and unsubscribe from stuff
};
});
Plugin.on
Register handlers for events of other plugins.
on('otherPlugin', {
eventName(pluginContext, arg1, arg2) {
// React to event from other plugin
},
});
Plugin.register
Register the plugin in the global store.
PluginContext
The plugin context API allows you to interact with private plugin data (config, state) as well as other plugins (emit events, call methods). All plugin handlers receive the plugin context as the first argument.
PluginContext.getConfig
Read own (private) plugin config.
pluginContext.getConfig();
PluginContext.getState
Read own (private) plugin state.
pluginContext.getState();
PluginContext.setState
Change own (private) plugin state.
pluginContext.setState({ enabled: true });
// Or using a state updater callback
pluginContext.setState(prevState => ({
...prevState,
enabled: !prevState.enabled,
}));
PluginContext.getMethodsOf
Get public methods of other plugins.
const otherPlugin = pluginContext.getMethodsOf('otherPlugin');
otherPlugin.doSomething('withThis');
// It's also possible for plugin methods to return something
const value = otherPlugin.getSomething();
PluginContext.emit
Emit event to listeners registered by other plugins via Plugin.on
.
pluginContext.emit('magicEvent', 'arg1', 'arg2');
Slots
Slots and plugs are reminiscent of the chicken or egg conundrum. In this case, however, the slot definitely came first. Without a root slot nothing gets rendered in the Cosmos UI.
Each plugin can define new slots by rendering Slot
and ArraySlot
components.
<Slot>
Render a slot that accepts a React component plug.
Prop | Description |
---|---|
name | Unique slot name. |
children | Optional children passed to plugs. |
slotProps | Optional plain object passed to plugs. |
<Slot name="coolSlot" />
Slots can pass children
to plugs, as well as data through the slotProps
prop:
<Slot name="coolSlot" slotProps={{ cool: true, level: 100 }}>
<p>This is cool.</p>
</Slot>
<ArraySlot>
Render a slot that accepts a list of React component plugs.
Prop | Description |
---|---|
name | Unique slot name. |
slotProps | Optional plain object passed to plugs. |
plugOrder | Optional list of plug names to enforce a sort order. |
<ArraySlot name="coolSlot" />
The plug order can be enforced using the plugOrder
prop:
<ArraySlot name="coolSlot" plugOrder={['plug1', 'plug2', 'plug3']} />
Built-in Plugins
The Cosmos UI is built entirely using this plugin API. It literally starts like this:
const root = createRoot(container);
root.render(<Slot name="root" />);
You can browse the complete list of built-in UI plugins at packages/react-cosmos-ui/src/plugins (opens in a new tab).
Here's a few examples of existing slots:
Type | Slot name | Description | Plug examples |
---|---|---|---|
ArraySlot | rendererAction | Renderer-related buttons. | Open fixture source. Go full screen. Toggle responsive preview. |
ArraySlot | navRow | Left-hand nav panel widgets. | Fixture search. Fixture bookmarks. Fixture tree view. |
Array | rendererPreview | Fixture preview placeholder. | Iframe renderer for web, status message for React Native. |
TypeScript
Like everything else in Cosmos, the UI plugin system is built with TypeScript. All plugin APIs are type-safe and use generics where needed.
The plugin system is built around something called a "spec". The spec is a static interface that allows plugins to interact with each other safely without having to be part of the same codebase.
If you browse the built-in plugins (opens in a new tab) you'll find a spec.ts
file inside each plugin. This is an example:
export type RouterSpec = {
name: 'router';
state: {
urlParams: PlaygroundParams;
};
methods: {
getSelectedFixtureId(): null | FixtureId;
selectFixture(fixtureId: FixtureId): void;
unselectFixture(): void;
};
events: {
fixtureSelect(fixtureId: FixtureId): void;
fixtureReselect(fixtureId: FixtureId): void;
fixtureUnselect(): void;
};
};
Bundling
Because UI plugins are injected at run-time into the pre-bundled Cosmos UI, they must be bundled separately. This means you'll need to use a bundler like Webpack or Rollup to create a single JS file that Cosmos can load directly in the browser. For the same reason, you need to compile your JSX and TypeScript code to plain JS.
When building your UI plugin make sure it doesn't bundle react
or react-plugin
inside it. The Cosmos UI plugin system only works when plugins tap into the global React and ReactPlugin instances. The easiest way achive this is by using Webpack externals
(opens in a new tab):
externals: {
'react': 'React',
'react-dom': 'ReactDom',
'react-plugin': 'ReactPlugin'
}
See the Boolean input plugin webpack config (opens in a new tab) for a complete example.
For a Vite equivalent for Webpack externals
see
vite-plugin-externals (opens in a new tab).
In the future we might use import maps (opens in a new tab) to remove the need for a bundler to author UI plugins. ESM support is tracked here.
What Will You Create?
All this might seem intimidating but I encourage you to try it out. Create a blank Cosmos plugin and start hacking. Add something you find useful. Make Cosmos your own.