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.
Plugin System
Section titled “Plugin System”Plugin Lifecycle
Section titled “Plugin Lifecycle”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
Creating a Plugin
Section titled “Creating a Plugin”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 }}Plugin Communication
Section titled “Plugin Communication”Plugins communicate via three channels:
Event Bus (plugin-to-plugin notifications):
// Listen for events from other pluginsthis.on('selection-cleared', (detail) => { /* ... */ });
// Emit to other plugins onlythis.emitPluginEvent('selection-cleared', { source: 'keyboard' });
// Emit to both plugins AND external addEventListener consumersthis.broadcast('sort-change', { sortModel: [...this.sortModel] });Query System (sync state retrieval):
// Declare queryable state in manifeststatic override readonly manifest = { queries: [{ type: 'canMoveColumn', description: 'Check movability' }],};
// Handle queriesoverride handleQuery(query: PluginQuery): unknown { if (query.type === 'canMoveColumn') return !column.pinned; return undefined;}
// Query from another pluginconst responses = this.grid.query<boolean>('canMoveColumn', column);Plugin Manifest
Section titled “Plugin Manifest”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.
Validated Properties
Section titled “Validated Properties”| Property | Required Plugin | Level |
|---|---|---|
editable | EditingPlugin | Column |
editor | EditingPlugin | Column |
editorParams | EditingPlugin | Column |
group | GroupingColumnsPlugin | Column |
pinned | PinnedColumnsPlugin | Column |
columnGroups | GroupingColumnsPlugin | Config |
Validation runs in the RenderScheduler.mergeConfig callback, after plugins are initialized. Error messages clearly state which plugin is missing and how to import it.
Hook performance budget
Section titled “Hook performance budget”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.
| Hook | When it runs | Impact | Rule of thumb |
|---|---|---|---|
processColumns() | Every data/config update | Low | Runs once per update — fine for non-trivial work |
processRows() | Every data update | Medium | Runs over full dataset — O(n) work is OK, O(n²) is not |
afterCellRender() | Every visible cell, every scroll frame | High | Keep < 0.1 ms per call; no DOM queries, no allocations |
afterRowRender() | Every visible row, every scroll frame | High | Same budget as cells; cache anything reusable in processRows() |
afterRender() | Every render cycle | Medium | Avoid 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.
Style injection
Section titled “Style injection”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 CSSthis.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 renderconst style = document.createElement('style');this.gridElement.appendChild(style);Wrapping plugins (host-DOM chrome)
Section titled “Wrapping plugins (host-DOM chrome)”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(), notafterRender(). 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-contentnode; 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.
Feature Registry
Section titled “Feature Registry”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.
Why Features?
Section titled “Why Features?”| Aspect | Features (recommended) | Plugins (advanced) |
|---|---|---|
| API | features: { selection: 'row' } | plugins: [new SelectionPlugin({ mode: 'row' })] |
| Import | import '@toolbox-web/grid/features/selection' | import { SelectionPlugin } from '@toolbox-web/grid/plugins/selection' |
| Dependencies | Auto-resolved | Manual ordering |
| Tree-shaking | Zero cost if unused | Must avoid importing unused plugins |
How It Works
Section titled “How It Works”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<string, factory></i>"]
D --> E
F["gridConfig.features"] --> G["createPluginsFromFeatures()"]
E --> G
G --> H["Plugin instances<br/>(ordered by dependencies)"]
Registration
Section titled “Registration”When a feature module is imported, it runs immediately at load time:
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.
Lazy Hook Design
Section titled “Lazy Hook Design”The grid core never references the feature registry directly. Instead, a hook function bridges them:
// feature-hook.ts — imported by grid coreexport let resolveFeatures: FeatureResolverFn | undefined; // Starts undefined!
// registry.ts — imported only when a feature is importedsetFeatureResolver(createPluginsFromFeatures); // Sets the hookIf 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.
Resolution Flow
Section titled “Resolution Flow”During plugin initialization:
// grid.ts — #initializePlugins()const features = this.#effectiveConfig?.features;if (features && resolveFeatures) { featurePlugins = resolveFeatures(features);}// Feature plugins ordered first, then explicit pluginsconst allPlugins = [...featurePlugins, ...explicitPlugins];Dependencies are auto-ordered: selection and editing always instantiate first, since other plugins (like clipboard or undoRedo) depend on them.
Tree-Shaking
Section titled “Tree-Shaking”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).
See Also
Section titled “See Also”- Authoring Guide — Step-by-step plugin tutorial with complete examples.
- Plugin Development API — Typed reference for
BaseGridPlugin,GridPlugin,PluginManifest. - Grid Architecture — Render scheduler, virtualization, and DOM structure that plugins integrate with.
- Framework Adapters — How React / Angular / Vue adapters extend the plugin pipeline.