Skip to content

Plugin Architecture

This page describes how the plugin and feature subsystems work inside @toolbox-web/grid. It is the contributor-facing companion to the Authoring Guide and to the typed BaseGridPlugin API reference.

For grid-core internals (render scheduler, virtualization, DOM structure, configuration), see the main Architecture page.

flowchart TB
    A["new Plugin()<br/><i>Constructor stores user config</i>"] --> B["attach(grid)<br/><i>Plugin receives grid ref, merges config</i>"]
    B --> C["Grid Render Cycle"]
    C --> D["User Interaction"]
    D --> E["detach()<br/><i>Cleanup on grid disconnect</i>"]

    subgraph C["Grid Render Cycle"]
        C1["processColumns() hook"]
        C2["processRows() hook"]
        C3["afterCellRender() hook (per cell)"]
        C4["afterRowRender() hook (per row)"]
        C5["afterRender() hook"]
    end

    subgraph D["User Interaction"]
        D1["onCellClick() hook"]
        D2["onKeyDown() hook"]
        D3["onScroll() hook"]
        D4["onCellMouseDown/Move/Up()"]
    end
import { BaseGridPlugin, CellClickEvent, type GridElement } from '@toolbox-web/grid';
import styles from './my-plugin.css?inline';
interface MyPluginConfig {
enabled?: boolean;
}
export class MyPlugin extends BaseGridPlugin<MyPluginConfig> {
readonly name = 'myPlugin';
override readonly styles = styles;
protected override get defaultConfig(): Partial<MyPluginConfig> {
return { enabled: true };
}
override attach(grid: GridElement): void {
super.attach(grid); // MUST call super
// Setup listeners with this.disconnectSignal for auto-cleanup
}
override afterRender(): void {
if (!this.config.enabled) return;
// Access DOM via this.gridElement
}
override onCellClick(event: CellClickEvent): boolean | void {
// Return true to prevent default behavior
}
}

Plugins communicate via three channels:

Event Bus (plugin-to-plugin notifications):

// Listen for events from other plugins
this.on('selection-cleared', (detail) => { /* ... */ });
// Emit to other plugins only
this.emitPluginEvent('selection-cleared', { source: 'keyboard' });
// Emit to both plugins AND external addEventListener consumers
this.broadcast('sort-change', { sortModel: [...this.sortModel] });

Query System (sync state retrieval):

// Declare queryable state in manifest
static override readonly manifest = {
queries: [{ type: 'canMoveColumn', description: 'Check movability' }],
};
// Handle queries
override handleQuery(query: PluginQuery): unknown {
if (query.type === 'canMoveColumn') return !column.pinned;
return undefined;
}
// Query from another plugin
const responses = this.grid.query<boolean>('canMoveColumn', column);

Plugins declare owned config properties via a static manifest:

import type { PluginManifest } from '@toolbox-web/grid';
static override readonly manifest: PluginManifest<EditingConfig> = {
ownedProperties: [
{ property: 'editable', level: 'column', description: 'the "editable" column property' },
{ property: 'editor', level: 'column', description: 'the "editor" column property' },
],
configRules: [
{
id: 'editing/invalid-edit-on',
severity: 'warn',
message: '"editOn: dblclick" has no effect when editable is false',
check: (config) => config.editOn === 'dblclick' && config.editable === false,
},
],
};

See the Custom Plugins guide → Plugin Manifest for the full schema (queries, events, incompatibleWith, hookPriority).

The grid validates at runtime that properties like editable: true are only used when the owning plugin is loaded, and provides helpful error messages with import hints.

PropertyRequired PluginLevel
editableEditingPluginColumn
editorEditingPluginColumn
editorParamsEditingPluginColumn
groupGroupingColumnsPluginColumn
pinnedPinnedColumnsPluginColumn
columnGroupsGroupingColumnsPluginConfig

Validation runs in the RenderScheduler.mergeConfig callback, after plugins are initialized. Error messages clearly state which plugin is missing and how to import it.

Every plugin hook runs in the grid’s hot path. Keep this table in mind when implementing them — work that’s cheap per cell becomes expensive when you multiply it by the number of visible cells and the frequency of scroll events.

HookWhen it runsImpactRule of thumb
processColumns()Every data/config updateLowRuns once per update — fine for non-trivial work
processRows()Every data updateMediumRuns over full dataset — O(n) work is OK, O(n²) is not
afterCellRender()Every visible cell, every scroll frameHighKeep < 0.1 ms per call; no DOM queries, no allocations
afterRowRender()Every visible row, every scroll frameHighSame budget as cells; cache anything reusable in processRows()
afterRender()Every render cycleMediumAvoid DOM queries; use cached refs from earlier hooks

Profiling slow plugins: open Chrome DevTools → Performance, record a scroll, and look for your plugin name in the flame chart. Work that consistently shows up in afterCellRender / afterRowRender should usually move to processRows() and be cached.

Plugins must not append <style> elements as children of <tbw-grid> — they get removed by replaceChildren() on the next render. Use one of:

// ✅ The `styles` property (recommended for plugins — adopted into the grid's stylesheets)
override readonly styles = `
.my-class { color: blue; }
`;
// ✅ registerStyles for runtime-injected / dynamic CSS
this.gridElement.registerStyles('my-id', '.my-class { color: blue; }');
// ✅ Standard global CSS also works (stylesheet, <style> in <head>)
// ❌ Child <style> nodes inside the grid are wiped on render
const style = document.createElement('style');
this.gridElement.appendChild(style);

Most plugins enrich the grid: they tag rows in processRows(), decorate cells in afterCellRender(), or handle interaction hooks. A few plugins instead wrap the grid — they take the freshly built grid DOM and render their own chrome (a header bar, a sidebar, a toolbar) around it. The Shell plugin is the canonical example.

Wrapping is not done with the per-cell/per-row hooks. It uses one dedicated lifecycle hook:

override afterStructuralRender(): void {
const root = this.grid?._renderRoot;
if (!root || root.querySelector('.my-chrome > .tbw-grid-content')) return;
// 1. Core has built a BARE grid containing `.tbw-grid-content`.
// 2. Relocate that existing node into your chrome — do NOT re-create it.
// Moving (not rebuilding) preserves the content subtree, its event
// listeners, and the grid's cached DOM refs.
const content = root.querySelector('.tbw-grid-content');
const chrome = buildChrome(); // header bar + body + sidebar, etc.
chrome.querySelector('[data-content-slot]')!.append(content!);
root.append(chrome);
}

Key contract for a wrapping plugin:

  • Use afterStructuralRender(), not afterRender(). It fires synchronously inside the same task as the structural DOM build, before paint — so there is no flash of unwrapped content. It runs only on full structural rebuilds (connect / structural change), never on the scroll or data hot path.
  • Move the .tbw-grid-content node; never replace it. The grid caches references into that subtree (body, viewport, rows). Re-creating it breaks scrolling, virtualization, and every plugin that cached a ref. Relocate the existing element into your wrapper.
  • Be idempotent. A full rebuild discards whatever chrome a previous invocation produced, so re-create it each time and early-return when the desired structure is already present.
  • Render your own content after wrapping. Inject titles, toolbar buttons, and panel bodies into the chrome you built — query them from this.grid._renderRoot, not from the grid-body refs (which point inside .tbw-grid-content).

See afterStructuralRender in the BaseGridPlugin reference for the full hook contract.


The features API is the recommended way to enable grid capabilities. It wraps the plugin system with declarative configuration and tree-shakeable side-effect imports.

AspectFeatures (recommended)Plugins (advanced)
APIfeatures: { selection: 'row' }plugins: [new SelectionPlugin({ mode: 'row' })]
Importimport '@toolbox-web/grid/features/selection'import { SelectionPlugin } from '@toolbox-web/grid/plugins/selection'
DependenciesAuto-resolvedManual ordering
Tree-shakingZero cost if unusedMust avoid importing unused plugins

Each feature module is a side-effect import that registers a factory function:

flowchart LR
    subgraph imports["SIDE-EFFECT IMPORTS"]
        A["import '.../features/selection'"]
        B["import '.../features/editing'"]
    end

    A -->|"module executes"| C["registerFeature('selection', factory)"]
    B -->|"module executes"| D["registerFeature('editing', factory)"]

    C --> E["featureRegistry<br/><i>Map&lt;string, factory&gt;</i>"]
    D --> E

    F["gridConfig.features"] --> G["createPluginsFromFeatures()"]
    E --> G
    G --> H["Plugin instances<br/>(ordered by dependencies)"]

When a feature module is imported, it runs immediately at load time:

libs/grid/src/lib/features/selection.ts
import { SelectionPlugin } from '../plugins/selection';
import { registerFeature } from './registry';
registerFeature('selection', (config) => {
// Shorthand strings → full config
if (config === 'cell' || config === 'row' || config === 'range') {
return new SelectionPlugin({ mode: config });
}
return new SelectionPlugin(config ?? undefined);
});

Each factory handles shorthand values (e.g., 'row'{ mode: 'row' }), so users get a simpler API.

The grid core never references the feature registry directly. Instead, a hook function bridges them:

// feature-hook.ts — imported by grid core
export let resolveFeatures: FeatureResolverFn | undefined; // Starts undefined!
// registry.ts — imported only when a feature is imported
setFeatureResolver(createPluginsFromFeatures); // Sets the hook

If no feature modules are imported, resolveFeatures stays undefined — the entire registry module is tree-shaken away by the bundler. This is why features are zero-cost when unused.

During plugin initialization:

// grid.ts — #initializePlugins()
const features = this.#effectiveConfig?.features;
if (features && resolveFeatures) {
featurePlugins = resolveFeatures(features);
}
// Feature plugins ordered first, then explicit plugins
const allPlugins = [...featurePlugins, ...explicitPlugins];

Dependencies are auto-ordered: selection and editing always instantiate first, since other plugins (like clipboard or undoRedo) depend on them.

Feature modules are marked as side-effects in package.json:

{ "sideEffects": ["./lib/features/*.js"] }

This tells bundlers: “these imports have side effects (registration), don’t optimize them away.” But un-imported features are never loaded, so only the features you use add to bundle size (~200–300 bytes each).

AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt