Skip to main content

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.

Questionview halfdata half
Installed throughDatasetOptions.plugins, or — chrome-only — GanttOptions.plugins, gantt.installPlugin, gantt.plugins =DatasetOptions.plugins — read-only after construction
When it runsOnce, after the Gantt mountsOnce, on the finished Dataset
Can it be added or removed later?A chrome-only plugin, yes — installPlugin/uninstallPlugin, live, no remountNo — 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 intoRenderers, decorations, variants, grid columns, commands, keybindings (§3 table)Fields, field types, aggregators, the mutation extension hook, its own store
Runtime that installs itPluginRuntime<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.

One plugin's install, setup and dispose Sequence diagram: a page calls gantt.installPlugin, which reaches GanttShell then PluginRuntime, which builds a context, calls the plugin's setup, closes its registration gate, and later disposes it on uninstallPlugin. harness page e2e/plugins.ts Gantt api/gantt.ts GanttShell view/gantt-shell.ts PluginRuntime extensions/plugin-runtime.ts RegistrationGate one per plugin the plugin setup(ctx) 1 gantt.installPlugin(plugin) 2 #shell.installPlugin(plugin) 3 install([...plugins, plugin]) 4 assertNoDuplicateIds; partition #installed by id → kept vs removed; this plugin's id is new → toAdd 5 new RegistrationGate(plugin.id) #buildContext(plugin.id) also builds ctx + disposables 6 plugin.setup(ctx) 7 ctx.view.registerRenderer(…), ctx.view.registerDecoration(…), ctx.commands.register(…) — any register*, any number of times each call reads gate.assertOpen() first 8 returns a Disposer, or nothing (review P4) 9 gate.close() a register* reached after this throws RegistrationClosedError 10 #installed = [...kept, ...justInstalled] the plugin is live — its registrations paint on the next frame — later, on the same Gantt — 11 gantt.uninstallPlugin(plugin.id) 12 install(plugins.filter(id ≠ this one)) 13 this plugin's id is now missing from next → removed dispose(): disposables.disposeAll() — every register*'s own removal, in reverse — then ownDispose?.() from step 8
Diagram 1 — one plugin, start to finish. Amber = the returned 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.

The registration gate over one plugin's lifetime Three bars on a shared timeline: setup() running with the gate open, installed and live with the gate closed, and disposed. install() setup() returns uninstallPlugin() setup(ctx) running — gate OPEN installed & live — gate CLOSED register* — legal, files a Disposer register* — throws RegistrationClosedError dispose disposables.disposeAll() then ownDispose?.() — see Diagram 1, step 13
Diagram 2 — the gate has exactly one open window per plugin, and it is the same window every plugin gets: its own 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().

install()'s diff, setup and rollback Flow diagram: partition the current and next plugin lists by id into kept, removed and toAdd; set up toAdd in order, rolling back on a throw; then dispose removed; then commit. install(next) #installed ← current list partition #installed by id nextIds.has(id) ? kept : removed #reportDroppedReconfigures next vs kept, same id different object toAdd = next − kept, by id try: for each plugin in toAdd 1. #buildContext(id) → ctx, gate 2. plugin.setup(ctx) → ownDispose? 3. gate.close() 4. justInstalled.push({plugin, dispose}) setup order = toAdd's own array order — the order the caller's next list names them catch (cause) unwind justInstalled, reverse: #disposeOne(each) throw PluginSetupError(failedId, cause) #installed untouched — old set stands no throw removed disposed, reverse: #disposeOne(each) after toAdd's own setup — a plugin swapping in sees the old one still live commit #installed = [...kept, ...justInstalled] Rollback only ever touches THIS call's own toAdd. kept and removed are never disposed on a throw — a half-applied plugin list never reaches a caller .
Diagram 3 — 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.

SeamCallWho wins when two plugins claim itWhat re-runs
rendererctx.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 againNext frame repaints (requestFrame)
decorationctx.view.registerDecoration(layer, fn)Every registration paints — the only seam where more than one wins at onceHeld provider list drops; next frame repaints
variantctx.variants.add(variant)Newest registration for that name winsPer-row Bar cache invalidates, capabilities re-resolve, variant styles refresh, next frame repaints
grid columnctx.view.registerGridColumn(column)A duplicate field the consumer's own gridColumns already names is dropped — config beats a pluginColumn 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.

Resolving Dataset plugin setup order from requires Two Dataset plugins where one requires the other, resolved into one setup order, then installed top to bottom with the same try/rollback shape as PluginRuntime. plugins: [b, a] b.requires = ['a'] resolveSetupOrder — DFS, visit(plugin) visit(b) → requires a → visit(a) first a settles, then b settles a cycle in requires throws PluginRequirementCycleError ordered: [a, b] array order ≠ input order for plugin of ordered: try 1. buildContext(id) → ctx, gate 2. plugin.setup(ctx) → ownDispose? 3. gate.close() 4. installed.push({id, dispose}) a's fields exist before b's setup runs — b may declare an Aggregator over a Field a itself declared setup throws unwind installed, reverse throw PluginSetupError the Dataset constructor throws, too — no half-built Dataset reaches a caller no throw returns one Disposer for the whole set Dataset.destroy() calls it once — every plugin disposes, in reverse setup order
Diagram 4 — 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. :::