Read OSS

Extensibility by Design: The Registry, Plugin System, and Component Lifecycle

Intermediate

Prerequisites

  • Articles 1-3
  • Understanding of JavaScript prototype chains
  • Basic knowledge of the observer/hook pattern

Extensibility by Design: The Registry, Plugin System, and Component Lifecycle

Chart.js supports bar, line, doughnut, radar, polar area, bubble, scatter, and pie charts out of the box—but the system doesn't treat any of these as special. They're all registered components, identical in status to any third-party chart type you might create. This uniformity is the result of two key abstractions: the Registry (which manages component discovery and defaults merging) and the PluginService (which orchestrates lifecycle hooks). Together, they form the extensibility backbone of Chart.js.

Registry and TypedRegistry Architecture

The Registry singleton holds four TypedRegistry instances, one for each component category:

src/core/core.registry.js#L11-L19

classDiagram
    class Registry {
        +controllers: TypedRegistry~DatasetController~
        +elements: TypedRegistry~Element~
        +plugins: TypedRegistry~Object~
        +scales: TypedRegistry~Scale~
        -_typedRegistries: TypedRegistry[]
        +add(...args)
        +remove(...args)
        -_each(method, args, typedRegistry?)
        -_getRegistryForType(type)
    }

    class TypedRegistry {
        +type: Constructor
        +scope: string
        +override: boolean
        +items: Record~string, Component~
        +isForType(type): boolean
        +register(item): string
        +unregister(item)
        +get(id): Component
    }

    Registry "1" *-- "4" TypedRegistry

When Chart.register(BarController, LinearScale, BarElement) is called, the Registry needs to figure out which TypedRegistry each argument belongs to. The _getRegistryForType() method at line 161 iterates _typedRegistries and calls isForType() on each:

src/core/core.registry.js#L161-L170

The isForType() check uses prototype chain inspection: Object.prototype.isPrototypeOf.call(this.type.prototype, type.prototype). So a BarController matches the controllers registry because DatasetController.prototype is in its prototype chain. A LinearScale matches the scales registry because Scale.prototype is in its chain.

The order of _typedRegistries matters: [controllers, scales, elements]. Since Scale extends Element, scales must be checked before elements, or every scale would be incorrectly registered as an element. Plugins are the fallback—anything that doesn't match the other three registries is treated as a plugin.

The _each() method at line 124 also handles loopable arguments—when you pass a namespace like import * as controllers, it iterates the object's values and registers each one individually:

src/core/core.registry.js#L124-L146

Component Registration: Defaults Merging and Route Setup

When a component is registered via TypedRegistry.register(), several things happen in sequence:

src/core/core.typedRegistry.js#L8-L53

flowchart TD
    REG["TypedRegistry.register(BarController)"] --> PROTO["Walk prototype chain"]
    PROTO --> PARENT{"Parent has id & defaults?"}
    PARENT -->|Yes| REC["Recursively register parent first"]
    PARENT -->|No| CONT["Continue"]
    REC --> CONT
    CONT --> CHECK{"Already registered?"}
    CHECK -->|Yes| SCOPE["Return existing scope"]
    CHECK -->|No| STORE["Store in items[id]"]
    STORE --> MERGE["registerDefaults:<br/>merge parent defaults + existing + item.defaults"]
    MERGE --> ROUTE{"Has defaultRoutes?"}
    ROUTE -->|Yes| ROUTES["routeDefaults:<br/>call defaults.route() for each"]
    ROUTE -->|No| DESC{"Has descriptors?"}
    ROUTES --> DESC
    DESC -->|Yes| DESCRIBE["defaults.describe(scope, descriptors)"]
    DESC -->|No| OVERRIDE{"Registry has override flag?"}
    DESCRIBE --> OVERRIDE
    OVERRIDE -->|Yes| OVERRIDES["defaults.override(id, item.overrides)"]
    OVERRIDE -->|No| DONE["Done"]
    OVERRIDES --> DONE

The registerDefaults() function at line 84 performs a three-way merge:

src/core/core.typedRegistry.js#L84-L101

  1. Parent scope defaults (e.g., DatasetController.defaults)
  2. Any existing defaults at the target scope
  3. The component's own defaults property

This means defaults cascade through the prototype chain. A BarController inherits DatasetController's defaults and can override specific values. The routeDefaults() call at line 103 then establishes live fallback chains via defaults.route(), as we explored in Article 3.

Tip: When creating a custom chart type, define static defaults = { ... } for base configuration and static defaultRoutes = { backgroundColor: 'color' } for properties that should fall back to global defaults. This ensures your component integrates naturally with the theme system.

PluginService and the Plugin Lifecycle

The PluginService class manages a rich lifecycle for plugins, with four distinct phases:

src/core/core.plugins.js#L20-L120

sequenceDiagram
    participant Chart as Chart
    participant PS as PluginService
    participant Plugin as Plugin

    Note over PS: On 'beforeInit' hook:
    PS->>PS: _createDescriptors(chart, true)
    PS->>Plugin: install(chart, args, options)

    Note over PS: Normal operation:
    PS->>Plugin: beforeInit / afterInit
    PS->>Plugin: beforeUpdate / afterUpdate
    PS->>Plugin: beforeLayout / afterLayout
    PS->>Plugin: beforeDraw / afterDraw
    PS->>Plugin: ...all other hooks...

    Note over PS: On 'afterDestroy' hook:
    PS->>Plugin: afterDestroy
    PS->>Plugin: stop(chart)
    PS->>Plugin: uninstall(chart)
    PS->>PS: _init = undefined

The lifecycle phases are:

  1. install — Called once when the chart initializes. Used for one-time setup like adding DOM elements.
  2. start — Called when a plugin becomes active on a chart (including after plugin cache invalidation and re-evaluation).
  3. Chart hooks — The standard before/after pairs for each lifecycle event (init, update, layout, datasets, draw, etc.).
  4. stop — Called when a plugin becomes inactive (e.g., disabled via options, or chart is being destroyed).
  5. uninstall — Called once during chart destruction, for final cleanup.

The notify() method at line 35 is the gateway for all hook calls. It handles the beforeInit hook specially—that's when _createDescriptors() is first called to build the plugin list, and install is invoked. The afterDestroy hook triggers the teardown sequence: stop then uninstall.

The invalidate() method at line 73 has a subtle safeguard against double-invalidation:

src/core/core.plugins.js#L73-L83

When plugins are re-registered, the cache might be invalidated twice before _descriptors() is called. The _oldCache pattern preserves the previous descriptor list so that _notifyStateChanges() can correctly determine which plugins were added or removed and call start/stop accordingly.

Anatomy of Built-in Plugins

The Colors Plugin (Simplest Example)

src/plugins/plugin.colors.ts#L94-L127

The Colors plugin is the minimal pattern. It has an id, defaults, and a single hook (beforeLayout). Its job is to auto-assign palette colors to datasets that don't define their own. The logic checks if any dataset already has explicit colors—if so, it backs off entirely unless forceOverride is true.

The palette is a simple 7-color array cycled via modulo. For doughnut and polar area charts, each data point gets its own color; for other chart types, each dataset gets a single border/background pair.

The Decimation Plugin (Algorithm-Focused)

src/plugins/plugin.decimation.js#L3-L60

The Decimation plugin implements the Largest Triangle Three Buckets (LTTB) algorithm for reducing large datasets to a visually representative subset. The algorithm divides data into buckets and selects the point from each bucket that forms the largest triangle with its neighbors—preserving the visual shape of the data while dramatically reducing the number of points to render.

The Filler Plugin (Multi-File Complexity)

src/plugins/plugin.filler/index.js#L12-L60

The Filler plugin shows what happens when plugin complexity grows. It's split across multiple files (index.js, filler.drawing.js, filler.helper.js, filler.options.js) and uses multiple hooks (afterDatasetsUpdate, beforeDraw, beforeDatasetsDraw, beforeDatasetDraw). It computes fill targets, resolves references between datasets (dataset A filled to dataset B), and handles the actual Canvas fill drawing.

Writing a Custom Plugin: Patterns and Hooks

A Chart.js plugin is any object with an id string and one or more hook methods. The minimal pattern:

const myPlugin = {
  id: 'myPlugin',
  
  defaults: {
    enabled: true,
    color: '#ff0000'
  },
  
  beforeDraw(chart, args, options) {
    if (!options.enabled) return;
    // options.color is resolved through the scope chain
    const ctx = chart.ctx;
    // Draw custom content...
  }
};

// Register globally
Chart.register(myPlugin);

// Or use per-chart
new Chart(ctx, {
  plugins: [myPlugin],
  options: {
    plugins: {
      myPlugin: { color: '#00ff00' }
    }
  }
});
flowchart LR
    subgraph "Plugin Resolution"
        GLOBAL["registry.plugins.items"] --> ALL["allPlugins()"]
        LOCAL["config.plugins array"] --> ALL
        ALL --> OPTS["Merge with options.plugins[id]"]
        OPTS --> RESOLVE["createResolver with plugin scopes"]
    end

The options parameter passed to each hook is already a fully resolved Proxy (from the engine we explored in Article 3). Plugin options are resolved against scopes ['plugins.{id}', ...additionalOptionScopes], which means a plugin can declare additionalOptionScopes: ['interaction'] to inherit interaction options automatically.

Hook methods receive three arguments: (chart, args, options). The args object varies by hook—for beforeDraw, it's mostly empty; for beforeDatasetUpdate, it includes { meta, index, mode, cancelable }. Returning false from a cancelable hook prevents the default behavior.

Bridging to the Next Article

We've seen how components are registered and how plugins hook into the lifecycle. But the most important components—Scales, Elements, and DatasetControllers—have rich internal mechanics of their own. In the next article, we'll trace the data-to-pixel pipeline: how raw data values flow through Scale transformations, become Element geometry, and ultimately produce Canvas draw calls.