Plugin lifecycle
What installPlugin does, when a plugin's own half may call a register*, and what
uninstallPlugin tears down. One plugin has two halves — view and data — and §4 is where the
data half differs.
Derived from api/plugin.ts, api/plugin-context.ts, api/dataset-plugin.ts,
extensions/plugin-runtime.ts, extensions/install-dataset-plugins.ts.
Two halves, one install site
A view half sees panes, the overlay and gestures. A data half sees only what the Dataset
holds, so it stays DOM-free and runs wherever a Dataset runs — the same core-vs-view split every
other layer keeps. One definePlugin object may fill both. The install site is where the state
lives: a plugin with a data half installs on the Dataset, and every Gantt bound to that
Dataset then runs its view half once, each with its own context.
| Question | view half | data half |
|---|---|---|
| Installed through | DatasetOptions.plugins, or — chrome-only — GanttOptions.plugins, gantt.installPlugin, gantt.plugins = | DatasetOptions.plugins — read-only after construction |
| When it runs | Once, after the Gantt mounts | Once, on the finished Dataset |
| Can it be added or removed later? | A chrome-only plugin, yes — installPlugin/uninstallPlugin, live, no remount | No — a data half declares what shapes the Dataset's own construction, and a Dataset installs its plugins once, so a different plugin set means a new Dataset |
| What it registers into | Renderers, decorations, variants, grid columns, commands, keybindings (§3 table) | Fields, field types, aggregators, the mutation extension hook, its own store |
| Runtime that installs it | PluginRuntime<TContext> | installDatasetPlugins() |
Setup order for either half resolves from requires alone — [a, b] and [b, a] install
identically, and one list covers both halves (§4 walks the resolution).
Both halves share the same three-part shape underneath, which is why the rest of this page reads
almost the same for either one: a plugin names an id; each half runs exactly once and may return
a Disposer; every register* it calls is legal only while that one call is on the stack. What
differs is what triggers install and whether it can be undone.
Install, setup, dispose — one view half
gantt.installPlugin(plugin) forwards into PluginRuntime.install, which builds one fresh
context, calls view() once, then closes that plugin's own registration gate the moment view()
returns. Nothing the plugin registered survives past its own uninstallPlugin call —
PluginRuntime disposes it for the plugin, whether or not the plugin returned a Disposer of its
own.
Derived from api/gantt.ts, view/gantt-shell.ts, extensions/plugin-runtime.ts,
view/plugin-ports.ts.
Disposer and the
teardown it feeds. The gate (column 5) is open for exactly one call: step 6. Every
register* reached after step 9 — including from a stray closure the plugin kept —
throws RegistrationClosedError instead of silently registering.
Why the gate closes the instant setup() returns
A plugin's registrations have exactly one lifetime: the plugin's own. If setup() could keep
registering after it returns — from a timer, a promise, a DOM callback it captured — nothing would
tell PluginRuntime to also retract that later registration when the plugin is uninstalled, and a
stray write could land after teardown started. Closing the gate turns that gap into a thrown error
at the call site instead of a silent leak. A plugin that needs to add or remove something after
setup() uses the seams built for exactly that: ctx.view.registerRenderer(...)'s returned
Disposer retracts a registration early, and ctx.interaction.registerKeyHandler is ungated on
purpose, because a popup opens and closes its own handler for as long as the plugin runs — not
once at install.
The registration gate, as a timeline
The same three phases, drawn the way this library draws everything else — as bars against a time
axis. register* is legal only in the first bar.
setup() call.
Diffing by id — what install() does with a whole list
gantt.plugins = [...] and gantt.installPlugin(one) both end at PluginRuntime.install(next).
It never disposes and rebuilds everything: it diffs next against what is already installed, by
id, and only the difference moves. A plugin present in both lists — even a fresh object for that
id — is left exactly as it was; PluginRuntime reports that as a dropped reconfigure rather than
silently ignoring it.
Derived from extensions/plugin-runtime.ts install().
toAdd is set up before any removed plugin is disposed, so a
plugin swapping in for another sees the outgoing one's resources still live during its own
setup(). A setup() throw unwinds only what this call just installed,
in reverse, then rethrows — the previous installed set, dropped plugins included, is untouched.
The four seams a Gantt plugin registers into
ctx.view/ctx.variants carry four registration points, one module
(view/plugin-registrations.ts), each with the one thing that has to run again after a
registration changes. Three of the four refresh inside that module; ctx.view.registerGridColumn
shares the shape but keeps its own refresh in ColumnChrome.
| Seam | Call | Who wins when two plugins claim it | What re-runs |
|---|---|---|---|
| renderer | ctx.view.registerRenderer(point, fn) | One slot per point — bar, gridCell, header, tooltip — and a second claim throws RendererAlreadyRegisteredError, naming the point and both plugin ids. bar used to hold one slot per variant name; that moved to ctx.variants.add, so bar is an ordinary point again | Next frame repaints (requestFrame) |
| decoration | ctx.view.registerDecoration(layer, fn) | Every registration paints — the only seam where more than one wins at once | Held provider list drops; next frame repaints |
| variant | ctx.variants.add(variant) | Newest registration for that name wins | Per-row Bar cache invalidates, capabilities re-resolve, variant styles refresh, next frame repaints |
| grid column | ctx.view.registerGridColumn(column) | A duplicate field the consumer's own gridColumns already names is dropped — config beats a plugin | Column chrome rebinds; a stale field's baked-in copy is stripped first |
Every one of these returns a Disposer that removes exactly its own registration, and every one
is also filed in the plugin's own ctx.disposables, so a plugin that never keeps the return value
still tears down cleanly on uninstallPlugin.
A Dataset plugin installs once
installDatasetPlugins runs once the Dataset is built, never again. There is no diff to
compute, because Dataset.plugins is read-only — a data half declares what shapes the Dataset's own
construction, and a Dataset installs its plugins once, so a different plugin set is a different
Dataset. What it does
compute is order: requires resolves into a setup sequence through a depth-first visit, the same
shape a topological sort always takes.
Derived from extensions/install-dataset-plugins.ts.
requires decides order, not array position. A required id nobody
installs throws MissingPluginError; a cycle throws
PluginRequirementCycleError naming every plugin in it. The same reverse-order
unwind-on-throw and reverse-order dispose that PluginRuntime uses (§3) reappear
here — one shape, two runtimes.
:::note Why setup order matters more for a data half
A data half can declare a Field and wrap the mutation extension hook (setExtender) — a second
plugin's wrapper receives the first's output, so which one ran first changes what the composed
hook does. A view half registers into slots that resolve by newest wins, not by wrapping. Both
halves read one requires list, so a Gantt sorts the Dataset's plugins together with its own
chrome.
:::