This app is the recommended starting point for developers building plugins for Cytoscape Web. It is intentionally kept small so you can read the entire source in one sitting, while still demonstrating every major integration pattern you will need.
| Field | Value |
|---|---|
| Federation name | hello |
| Dev server port | 2222 |
| Entry point (local dev) | hello@http://localhost:2222/remoteEntry.js |
Cytoscape Web uses Module Federation (Vite) to load plugins at runtime without rebuilding the host application.
┌─────────────────────────────┐ ┌──────────────────────────┐
│ Cytoscape Web (host) │ │ Your plugin app │
│ localhost:5500 │ │ localhost:2222 │
│ │ │ │
│ Exposes via cyweb/ prefix: │ │ Exposes: │
│ cyweb/ApiTypes │◄──────│ ./AppConfig ← loads │
│ cyweb/WorkspaceApi │ │ │
│ cyweb/VisualStyleApi │ │ Imports from host: │
│ cyweb/SelectionApi │──────►│ cyweb/WorkspaceApi │
│ cyweb/LayoutApi │ │ cyweb/VisualStyleApi │
│ cyweb/EventBus │ │ cyweb/EventBus … │
│ … (9 use*Api hooks + │ │ │
│ AppIdContext, EventBus,│ │ │
│ ApiTypes) │ │ │
└─────────────────────────────┘ └──────────────────────────┘
The host loads your plugin by fetching remoteEntry.js from your server,
reading the ./AppConfig export, and rendering your panel or menu components
inside the host UI. React, ReactDOM, and MUI are shared singletons — your
plugin does not bundle its own copy, which keeps the download small.
- Node.js 24+ (see
.nvmrcat the repo root) - The Cytoscape Web host app running at
localhost:5500(see the host repo) - Your plugin registered in the host's
src/assets/apps.local.json(already done for this app)
# Terminal 1 — start the host with the local app registry
cd ../../cytoscape-web
npm install
npm run dev:local # → http://localhost:5500 (loads apps.local.json)
# Terminal 2 — start this plugin
cd hello-world
npm install
npm run dev # → http://localhost:2222Use
npm run dev:local(notnpm run dev) so the host loadssrc/assets/apps.local.jsonand discovers your locally running plugin.
Open http://localhost:5500, click Apps in the toolbar, then App
Settings to enable the Hello World app. The panel appears on the right side.
hello-world/
├── src/
│ ├── index.ts ← exposed as ./AppConfig; re-exports HelloApp as default
│ ├── HelloApp.tsx ← app config + lifecycle (mount / unmount)
│ ├── lifecycleState.ts ← external store bridging lifecycle ↔ React
│ └── components/
│ ├── HelloPanel.tsx ← root panel layout (composes examples below)
│ ├── HelloHeader.tsx ← Example 0: MUI + finding your own chunk URL
│ ├── VisualStyleSection.tsx ← Example 1: VisualStyleApi
│ ├── SelectionSection.tsx ← Example 2: EventBus + SelectionApi
│ ├── LayoutSection.tsx ← Example 3: LayoutApi + EventBus (async)
│ ├── LifecycleSection.tsx ← Example 4: App lifecycle / useSyncExternalStore
│ ├── MenuSection.tsx ← Example 5: Menu component pattern
│ ├── ContextMenuSection.tsx ← Example 6: Context menu items via useAppContext
│ ├── ElementSection.tsx ← Example 7: Node/edge CRUD via ElementApi
│ ├── TableSection.tsx ← Example 8: Table data read/write via TableApi
│ ├── ViewportSection.tsx ← Example 9: Fit viewport, node positions via ViewportApi
│ ├── ExportSection.tsx ← Example 10: CX2 export via ExportApi
│ ├── NetworkSection.tsx ← Example 11: Network create/delete via NetworkApi
│ ├── TsvDownloadSection.tsx ← Example 12: TSV table download via TableApi
│ └── NetworkSummaryMenuItem.tsx ← apps-menu item registered in HelloApp.tsx
├── vite.config.ts ← Module Federation config
├── index.html ← remote-only stub (Vite needs an HTML entry)
├── src/cywebHostSentinel.ts ← entry a production build ships when no host is known
├── src/mfRuntimePlugin.ts ← resolves the host URL at runtime
├── test/mfRuntimePlugin.test.ts ← covers both remote arrays + every rejection
├── tsconfig.json / .node.json / .test.json
└── package.json
Every Cytoscape Web plugin exports a CyAppWithLifecycle object from its
entry point. This object is the single source of truth for the host about your
app's identity, components, and lifecycle.
export const HelloApp: CyAppWithLifecycle = {
id: 'hello', // must match the federation `name` in vite.config.ts
name: 'Hello Cytoscape World App',
description: '…',
version, // imported from package.json — stays in sync automatically
apiVersion: '1.0',
// Declarative resource registration (Phase 2) — panels and menu items
resources: [
{
slot: 'right-panel',
id: 'HelloPanel',
title: 'Hello World',
component: lazy(() => import('./components/HelloPanel')),
},
{
slot: 'apps-menu',
id: 'NetworkSummaryMenuItem',
title: 'Network Summary',
component: lazy(() => import('./components/NetworkSummaryMenuItem')),
},
],
mount(context) { … }, // context menu items + event listeners
unmount() { … }, // only manual cleanup (event listeners)
}Normally each exposed component needs its own exposes entry. By
calling React.lazy() here in the config file, we avoid that: the host
receives the lazy component reference directly from ./AppConfig and renders
it without a second network round-trip. Your Vite config only needs to
expose one entry:
exposes: {
'./AppConfig': './src/index.ts', // ← everything flows from here
}mount(context) is called once after your components are registered and the
host API is fully initialised. unmount() is called when the user disables
the app in App Settings, or when the page unloads.
mount(context: AppContext): void {
// context.apis extends window.CyWebApi with per-app resource + contextMenu.
const result = context.apis.workspace.getCurrentNetworkId()
// Context menu items are registered here because handlers need apis access.
// Items are auto-cleaned when the app is disabled — no explicit removal needed.
context.apis.contextMenu.addContextMenuItem({
label: 'Hello: Log Node Info',
targetTypes: ['node'],
handler: (ctx) => { /* use context.apis here */ },
})
// App-scoped event listeners — must be cleaned up in unmount().
_handler = (e) => { /* … */ }
window.addEventListener('network:switched', _handler)
},
unmount(): void {
// Only event listeners need manual cleanup.
// Resources (panels, menus) and context menu items are auto-cleaned by host.
window.removeEventListener('network:switched', _handler)
_handler = null
},Every Cytoscape Web API function returns ApiResult<T> — a discriminated
union that never throws across the API boundary.
const result = workspaceApi.getCurrentNetworkId()
if (result.success) {
const { networkId } = result.data // typed — safe to use
} else {
setErrorMessage(result.error.message) // show to the user
}Always check result.success before accessing result.data. Never call
.data on a failed result — TypeScript will flag this as a type error.
See VisualStyleSection.tsx for a complete in-panel error display example.
What it shows:
- MUI components (
Typography,Box) are shared singletons provided by the host at runtime, so your app bundles none of MUI. Import from the root barrel:import { Box } from '@mui/material'. A subpath import such as@mui/material/Boxmisses the share key and bundles MUI into your app instead, giving you a second Emotion cache and broken theming.npm run check:importsenforces this. import.meta.urltells you where the current module was served from.
import { Box, Typography } from '@mui/material'
const moduleUrl = import.meta.url // URL of THIS chunkThis example changed with the Vite migration, and not like for like. It used to read
__webpack_public_path__, a Webpack-injected global pointing at the container ROOT, so`${publicPath}remoteEntry.js`resolved. There is no ESM equivalent.import.meta.urlis the URL of this chunk, which in a production build lives underassets/— appending to it yields…/assets/remoteEntry.js, a 404. Reconstructing the entry URL from a chunk path is guessing, so the example shows the chunk URL for what it is.
What it shows:
- How to call a host App API hook from a React component.
- The complete error-handling pattern using local
useStatefor error messages. useWorkspaceApi().getCurrentNetworkId()— the standard first step before any network-scoped API call, because all operations require a network ID.
const workspaceApi = useWorkspaceApi()
const visualStyleApi = useVisualStyleApi()
const handleUpdateStyle = () => {
const networkResult = workspaceApi.getCurrentNetworkId()
if (!networkResult.success) { setErrorMessage(networkResult.error.message); return }
const styleResult = visualStyleApi.setDefault(
networkResult.data.networkId,
VisualPropertyName.NodeBackgroundColor,
'#ff0000',
)
if (!styleResult.success) setErrorMessage(styleResult.error.message)
}Key rule: Always resolve the current network ID at the time of the action, not during component initialisation. The active network may change between renders.
What it shows:
useCyWebEvent(eventType, handler)— subscribes to a host event with automatic cleanup when the component unmounts. This is the React-friendly pattern for event bus consumption.- The handler must be stable (wrap in
useCallbackwith[]deps) so theuseEffectinsideuseCyWebEventdoes not re-register on every render. network:switchedfires when the user navigates to a different network. Reset all network-scoped state here.selection:changedfires after every node/edge selection change. Thedetailcontains arrays of selected node and edge IDs.
// Stable handler — useCallback with empty deps
const handleNetworkSwitched = useCallback(({ networkId }) => {
setCurrentNetworkId(networkId)
setSelection({ nodes: 0, edges: 0 }) // reset on network change
}, [])
useCyWebEvent('network:switched', handleNetworkSwitched)
const handleSelectionChanged = useCallback(({ selectedNodes, selectedEdges }) => {
setSelection({ nodes: selectedNodes.length, edges: selectedEdges.length })
}, [])
useCyWebEvent('selection:changed', handleSelectionChanged)What it shows:
- Triggering an async host operation and tracking completion via both the returned Promise and the event bus.
layoutApi.applyLayout(networkId)is async. The Promise resolves when the algorithm finishes (or rejects on error). Thelayout:completedevent fires at the same time, allowing multiple components to react independently.- Disable the trigger button while the operation runs to prevent duplicate submissions.
const [status, setStatus] = useState<'idle' | 'running' | 'done'>('idle')
useCyWebEvent('layout:completed', useCallback(() => setStatus('done'), []))
const handleApply = () => {
setStatus('running')
layoutApi.applyLayout(networkId)
.then(result => { if (!result.success) setStatus('idle') })
.catch(() => setStatus('idle'))
// Always .catch() even when using the event bus — the event does not
// carry error information, only the Promise does.
}What it shows:
- The difference between
useCyWebEvent(component-scoped, auto-cleanup) andwindow.addEventListenerinmount()(app-scoped, manual cleanup). - How
mount(context)gives access to all APIs without a React rendering context. - How to bridge non-React state into React using
useSyncExternalStore— the standard React hook for subscribing to external stores.
lifecycleState.ts is a lightweight pub-sub module that HelloApp.mount()
writes to and LifecycleSection reads from:
// lifecycleState.ts — the bridge between lifecycle and React
export const getLifecycleSnapshot = (): LifecycleState => _state
export const subscribeLifecycleState = (fn: () => void) => {
_listeners.add(fn)
return () => _listeners.delete(fn) // returns unsubscribe
}
export const setLifecycleState = (patch: Partial<LifecycleState>) => {
_state = { ..._state, ...patch }
_listeners.forEach(fn => fn())
}// HelloApp.tsx — app-level listener, outside React
mount(context) {
setLifecycleState({ mounted: true })
const network = context.apis.workspace.getCurrentNetworkId()
if (network.success) setLifecycleState({ lastNetworkId: network.data.networkId })
_handler = (e) => {
const { networkId } = (e as CustomEvent).detail
setLifecycleState({
lastNetworkId: networkId,
networkSwitchCount: getLifecycleSnapshot().networkSwitchCount + 1,
})
}
window.addEventListener('network:switched', _handler)
},
unmount() {
window.removeEventListener('network:switched', _handler)
_handler = null
setLifecycleState({ mounted: false })
}// LifecycleSection.tsx — subscribes to external state from React
const { mounted, networkSwitchCount, lastNetworkId } = useSyncExternalStore(
subscribeLifecycleState, // stable module-level ref — no useCallback needed
getLifecycleSnapshot,
)When to use mount() vs useCyWebEvent():
useCyWebEvent() |
mount() + addEventListener |
|
|---|---|---|
| Scope | Per component instance | Entire app lifetime |
| Cleanup | Automatic (via useEffect) |
Manual in unmount() |
| React context required | Yes | No |
| Typical use | UI event reactions | Background tasks, SDKs, analytics |
Explains how ComponentType.Menu items (now via resources with
slot: 'apps-menu') work and the handleClose prop contract.
What it shows:
useAppContext().apis.contextMenu— the Phase 2 per-app context menu API.- Interactive toggle: add/remove context menu items for node, edge, and canvas.
ApiResult<{ itemId }>pattern for tracking registered items.useEffectcleanup for removing items when the component unmounts.
What it shows:
elementApi.createNode(networkId, position, options)— create nodes with random positions and attributes.elementApi.createEdge(networkId, source, target)— connect nodes.elementApi.deleteNodes(networkId, nodeIds)— remove nodes (incident edges are deleted automatically).- Each operation adds an undo entry automatically.
What it shows:
tableApi.getRow(networkId, 'node', elementId)— read all attributes for the first selected node.tableApi.createColumn(networkId, 'node', name, dataType, defaultValue)— add a new column to the node table.- Combines
selectionApi.getSelection()to pick the target node. - Write operations fire
data:changedevents automatically.
What it shows:
viewportApi.fit(networkId)— async fit-to-content (delegates to renderer).viewportApi.getNodePositions(networkId, nodeIds)— read[x, y]positions for selected nodes.- Positions are plain
[x, y, z?]tuples in aRecord<IdType, number[]>.
What it shows:
exportApi.exportToCx2(networkId)— assemble the full CX2 document from the host's stores (network, tables, visual style, view model).- The returned
Cx2is a JSON-serializable array of aspect objects. - Display a summary (aspect count, byte size) instead of the full document.
What it shows:
networkApi.createNetworkFromEdgeList({ name, edgeList, addToWorkspace })— the simplest way to create a network from scratch.networkApi.deleteCurrentNetwork()— remove the active network.workspaceApi.getWorkspaceInfo()— read workspace metadata (name, network count, current network ID).- Network creation fires
network:createdandnetwork:switchedevents.
What it shows:
tableApi.exportTableToTsv(networkId, 'node' | 'edge')— serialise the current node or edge attribute table as a tab-separated string.- The browser-native
Blob+URL.createObjectURL+<a download>pattern triggers a save-file dialog without any server round-trip. - Edge-table TSV always contains the
sourceandtargetcolumns.
const result = tableApi.exportTableToTsv(networkId, 'node')
if (result.success) {
const blob = new Blob([result.data.tsv], { type: 'text/tab-separated-values' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'node-table.tsv'
a.click()
URL.revokeObjectURL(url)
}Use project-template/ as the starting point:
cp -r ../project-template ../my-app
cd ../my-apppackage.json— changenameandversionvite.config.ts— changeDEV_SERVER_PORT(pick an unused port) andnameinfederation()(unique camelCase string, no spaces)src/— rename and replace the template files; keepindex.tsas the entry point re-exporting your app config asdefault- Host registry — add an entry to the JSON array in
../../cytoscape-web/src/assets/apps.local.json:
{
"id": "myApp",
"name": "My App (display name)",
"url": "http://localhost:XXXX/remoteEntry.js",
"author": "Your Name",
"description": "Short description",
"version": "0.1.0"
}The
idfield is the unique identifier and must match your app'sidand the federationname. Thenamefield is the human-readable label shown in App Settings.
- Run
npm run devand reload the host athttp://localhost:5500
Import any of these in your React components using the cyweb/ prefix:
| Import | Purpose |
|---|---|
cyweb/WorkspaceApi |
Get current network ID, list networks |
cyweb/ElementApi |
Create / delete nodes and edges |
cyweb/NetworkApi |
Create / delete networks, import CX2 |
cyweb/SelectionApi |
Read and mutate the current selection |
cyweb/VisualStyleApi |
Read and set visual properties |
cyweb/LayoutApi |
Run layout algorithms |
cyweb/ViewportApi |
Pan, zoom, fit the viewport |
cyweb/TableApi |
Read and write node/edge attribute tables |
cyweb/ExportApi |
Export the network as CX2 or image |
cyweb/EventBus |
Subscribe to host events (useCyWebEvent) |
cyweb/AppIdContext |
Per-app context (useAppContext) for resource and context menu APIs |
cyweb/ApiTypes |
TypeScript types for all of the above |
All API functions return ApiResult<T> — check result.success before using
result.data.
| Event | When it fires |
|---|---|
network:created |
A new network is added to the workspace |
network:deleted |
A network is removed |
network:switched |
The user navigates to a different network |
selection:changed |
Node or edge selection changes |
layout:started |
A layout algorithm begins |
layout:completed |
A layout algorithm finishes successfully |
style:changed |
A visual style property changes |
data:changed |
Node or edge attribute data changes |
This project follows the shared config in the repo root:
- No semicolons, single quotes, trailing commas, 2-space indent (Prettier)
- Import sorting enforced by ESLint (
eslint-plugin-simple-import-sort) - Functional React components only — do not add
import React from 'react'(the new JSX transform handles it automatically) - No
console.login committed code