Skip to content

Possible plugin lifecycle regression in Chart.js 4.5.1 causing chartjs-plugin-datalabels to draw before internal labels are prepared #12269

Open

Description

@bubbajdf

Expected behavior

After upgrading from Chart.js 4.5.0 to 4.5.1, charts using chartjs-plugin-datalabels 2.2.0 can fail during render with:

TypeError: can't access property "length", labels is undefined

The stack points into chartjs-plugin-datalabels.js during its draw/layout step, where it expects a prepared labels array.

The same chart configuration works with Chart.js 4.5.0.

Suspicious Chart.js change
The likely related change is in src/core/core.plugins.js.

In 4.5.0, PluginService initialized _init as an empty array:

constructor() {
this._init = [];
}

In 4.5.1, _init is initialized as undefined, and plugin notifications return early if beforeInit has not populated it:

constructor() {
this._init = undefined;
}

notify(chart, hook, args, filter) {
if (hook === 'beforeInit') {
this._init = this._createDescriptors(chart, true);
this._notify(this._init, chart, 'install');
}

if (this._init === undefined) {
return;
}

...
}

Hypothesis
Some plugin lifecycle notifications may be skipped, so chartjs-plugin-datalabels does not receive the update hooks it uses to build its internal label layout, but later still reaches its draw hook. That leaves its internal labels undefined and causes the crash.

Environment
Chart.js: 4.5.1
Working version: 4.5.0
Plugin: chartjs-plugin-datalabels 2.2.0
Browser: include browser/version if known
Chart type: include the failing chart type, e.g. bar/pie

Minimal symptom
The failing plugin line is equivalent to:

for (i = 0, ilen = labels.length; i < ilen; ++i) {

where labels is unexpectedly undefined.

Current behavior

Question
Was the PluginService._init === undefined guard intended to skip all plugin hooks before beforeInit, and could it cause inline/per-chart plugins such as ChartDataLabels to miss setup hooks but still receive draw hooks?

I tried to create a minimal CodePen using Chart.js 4.5.1 and chartjs-plugin-datalabels 2.2.0, but I have not been able to reproduce the error outside our REDCap page yet. The issue may depend on our page lifecycle or how the charts are created in that environment.

I understand this may not be actionable without a reproduction, but I wanted to ask whether the PluginService._init change in 4.5.1 could plausibly cause plugin setup hooks to be skipped while later draw hooks still run.

Reproducible sample

https://www.chartjs.org/dist/master/chart.umd.min.js

Optional extra steps/info to reproduce

No response

Possible solution

No response

Context

No response

chart.js version

v4.5.1

Browser name and version

No response

Link to your project

No response

Activity

  1. yfwmaniish commented on Aug 25, 2026

    @yfwmaniish

    Confirmed the diff you described is accurate -- src/core/core.plugins.js between v4.5.0 and v4.5.1:

       constructor() {
    -    this._init = [];
    +    this._init = undefined;
       }
    
       notify(chart, hook, args, filter) {
         if (hook === 'beforeInit') {
           this._init = this._createDescriptors(chart, true);
           this._notify(this._init, chart, 'install');
         }
    
    +    if (this._init === undefined) { // Do not trigger events before install
    +      return;
    +    }
    +
         ...
         if (hook === 'afterDestroy') {
           this._notify(descriptors, chart, 'stop');
           this._notify(this._init, chart, 'uninstall');
    +      this._init = undefined; // Do not trigger events after uninstall
         }

    That part of your hypothesis is right. However, tracing the actual call sequence in core.controller.js, _initialize() calls notifyPlugins('beforeInit') as its very first statement, before resize()/bindEvents()/afterInit, and every other notify() call site I found (update, render, draw, destroy, etc.) all go through the same Chart.prototype.notifyPlugins → this._plugins.notify(this, hook, ...) path after construction completes. So for a chart constructed the normal way, beforeInit should always run (and set _init) well before any draw-adjacent hook could reach the new guard -- I couldn't find an ordinary path where a draw hook fires while _init is still undefined.

    Two things that could still produce what you're seeing, worth checking if you get a chance to narrow the REDCap repro:

    1. Dynamic (un)registration: if chartjs-plugin-datalabels (or your app) calls Chart.register(...)/Chart.unregister(...) on an already-constructed chart's plugin after afterDestroy has run once (_init reset to undefined there) but before a subsequent full re-_initialize() on the same Chart instance -- if anything in your app reuses a Chart object across destroy() + partial re-setup rather than always constructing fresh, that reset is new in 4.5.1 and could plausibly cause exactly this "hooks silently no-op" symptom.
    2. Whether chartjs-plugin-datalabels itself calls chart.notifyPlugins(...) or otherwise pokes _plugins directly rather than only receiving hooks passively -- I didn't check the plugin's source for that.

    Not filing a PR without being able to reproduce the actual trigger, but wanted to confirm the code-diff part of your report so it doesn't get dismissed as unverified.

  2. bubbajdf commented on Aug 27, 2026

    @bubbajdf
    Author

    I can reproduce this with Chart.js 4.5.1 and chartjs-plugin-datalabels 2.2.0 using a responsive pie chart.

    The stack trace shows the failure during initial chart construction:

    _initialize
    → bindEvents
    → bindResponsiveEvents
    → resize
    → render
    → _drawDatasets
    → notifyPlugins
    → datalabels.afterDatasetsDraw

    At that point, DataLabels’ beforeInit hook has run, but its internal _labels array is still undefined. The stack trace indicates that afterDatasetsDraw is being invoked during the initial responsive resize/render, before the normal update cycle has prepared the labels.

    This results in:

    TypeError: can't access property "length", labels is undefined

    The same chart configuration works with Chart.js 4.5.0. This does not appear to involve dynamic registration, destroyed-chart reuse, or DataLabels directly calling notifyPlugins().

    Setting responsive: false avoids the bindResponsiveEvents path, which may be a useful diagnostic or temporary workaround. This appears to be an initial responsive resize/render occurring before the first update, or a related lifecycle-ordering regression in Chart.js 4.5.1.

  3. HuzaifaChaudary commented on Aug 29, 2026

    @HuzaifaChaudary

    i went looking at this because the diff in the comments above is real but i do not think it can be the cause, and i would rather be corrected than have someone spend a day on it.

    why the _init change does not explain it. the early return only fires when _init is undefined, and notify sets _init on the beforeInit hook itself, one line above the check:

    if (hook === 'beforeInit') {
      this._init = this._createDescriptors(chart, true);
      this._notify(this._init, chart, 'install');
    }
    
    if (this._init === undefined) { // Do not trigger events before install
      return;
    }

    _initialize() calls notifyPlugins('beforeInit') first, so by the time anything else is notified _init is populated. the change suppresses notifications that used to reach plugins before install. it cannot make a plugin receive a hook it did not receive in 4.5.0, only fewer of them.

    what i did find. there is a genuine draw before update window, and it is not in core.plugins.js. _doResize is a debounce, and debounce returns the delay:

    this._doResize = debounce(mode => this.update(mode), options.resizeDelay || 0);
    if (this._doResize(mode)) {
      // The resize update is delayed, only draw without updating.
      this.render();
    }

    with resizeDelay at its default of 0 the debounce runs update inline and returns 0, so the render is skipped. with any non zero resizeDelay it returns that number, which is truthy, so render() runs straight away while the update is still pending. i measured the hooks on a responsive pie chart with resizeDelay: 50:

    chart.resize(400, 400)  ->  resize -> beforeRender -> afterDatasetsDraw
    

    no update between the resize and the draw. that is the shape your stack trace shows.

    what i could not reproduce. i could not get afterDatasetsDraw to fire before the first afterUpdate at construction. on my build the order comes out as

    beforeInit -> resize -> afterInit -> beforeUpdate -> afterUpdate -> beforeRender -> afterDatasetsDraw
    

    because the resize inside _initialize happens before bindEvents, when attached is still falsy, so the render() branch is not taken. your trace has the resize coming from bindResponsiveEvents, which is after that point and before the constructors own update(), so i think it needs the container to actually change size as the observer attaches.

    so, two questions that would settle it, @bubbajdf:

    1. does your config set resizeDelay, and does it still happen with it at 0
    2. is the canvas in a container that changes size right as the chart attaches, for example one that is hidden or zero width for a tick

    if the answer to 1 is that you do set it, this is a chart.js side ordering question and worth a fix here. if not then i am wrong somewhere and would like to know where.

    for what it is worth _layers is not the culprit either, it is initialised to [] in the constructor at core.controller.js:154, so a draw before the first update does not throw on it. the length in your error is the plugins own array.

  4. HuzaifaChaudary commented on Aug 30, 2026

    @HuzaifaChaudary

    i was partly wrong above and i would rather correct it than have someone dig in the wrong place.

    the resizeDelay window i described is real but it cannot be what changed in 4.5.1. src/core/core.controller.js is byte identical between v4.5.0 and v4.5.1 so _resize and _doResize and the render() branch all behaved the same in both. only three runtime files changed in that release:

    src/controllers/controller.doughnut.js
    src/core/core.plugins.js
    src/helpers/helpers.dom.ts
    

    the one that matters is helpers.dom.ts. 4.5.1 swapped Math.floor for round1 in retinaScale and the canvas dimensions are written from that value directly:

    const deviceHeight = round1(chart.height * pixelRatio);
    const deviceWidth = round1(chart.width * pixelRatio);
    ...
    if (chart.currentDevicePixelRatio !== pixelRatio
        || canvas.height !== deviceHeight
        || canvas.width !== deviceWidth) {
      canvas.height = deviceHeight;
      canvas.width = deviceWidth;

    canvas.width and canvas.height are unsigned long. the browser truncates whatever you assign to them. so when the container measures to a fraction the value that gets stored is never the value being compared against and the branch stays true forever. measured in chrome headless on a 300.4 x 150.2 chart:

    canvas.width = 600.8   -> 600
    4.5.1 logic            -> true true true
    master                 -> true false false
    

    that return value is the early exit in _resize:

    if (!retinaScale(this, newRatio, true)) {
      return;
    }

    on 4.5.0 a second resize of a chart that had not really changed size stopped right there. on 4.5.1 with a fractional size it never stops. so the resize that bindResponsiveEvents fires as the observer attaches now runs the whole body instead of returning early. that is exactly where your trace goes and it also explains why responsive: false avoids it since that path skips bindResponsiveEvents altogether.

    this is already fixed on master by #12142 which floors the canvas dimensions again. it is just not in a release yet. it landed without a test so i opened #12293 to add one.

    one thing i still cannot close. the render() call inside _resize only runs when _doResize returns truthy and that needs resizeDelay to be non zero. with it at 0 the debounce calls update inline and returns 0 so the update happens before the draw. so if you do set resizeDelay the whole chain is explained. if you do not set it then something else is still making that branch run and i would want to see the real config.

    either way @bubbajdf it is worth retesting against master rather than 4.5.1 since the resize storm itself is gone there.

  5. bubbajdf commented on Aug 31, 2026

    @bubbajdf
    Author

    I initially suspected core.plugins.js, but that appears to be incorrect. The relevant 4.5.1 change is in src/helpers/helpers.dom.ts: retinaScale changed from Math.floor to round1 for canvas dimensions.
    Because canvas.width and canvas.height truncate fractional assignments, fractional container sizes can make the resize comparison remain true indefinitely. This causes a resize/render loop when resizeDelay is nonzero and can trigger the downstream chartjs-plugin-datalabels error.
    I confirmed that master fixes this by flooring the assigned canvas dimensions again. I also opened PR #12293 to add a regression test.

  6. HuzaifaChaudary commented on Aug 31, 2026

    @HuzaifaChaudary

    good, that lines up.

    small thing on #12293, that one is mine. i opened it yesterday about a minute before the comment above, so the regression test is already covered and nobody needs to write a second one. it pins the exact case, a 300.4 x 150.2 chart where canvas.width = 600.8 stores 600 so the comparison never settles.

    the part that is still open is the one i could not close myself, and it is yours to answer rather than mine:

    the render() call inside _resize only runs when _doResize returns truthy, and that needs resizeDelay to be non zero. at resizeDelay: 0 the debounce calls update inline and returns 0, so the update lands before the draw and there is no window at all.

    so there are two cases and they need different things:

    • your config sets resizeDelay then the chain is fully explained, master already fixes it through Use integer canvas dimensions #12142, and this can close once that ships
    • your config does not set it then something else is making that branch run and the resize storm is only half the story

    which is it? if you can paste the options you construct the chart with, that settles it either way. right now the issue reads as explained and it is not quite.

  7. bubbajdf commented on Aug 31, 2026

    @bubbajdf
    Author

    My REDCap configuration does set resizeDelay: 250. The chart is a responsive pie chart using ChartDataLabels. I tested against the live master build and confirmed that it contains the Math.floor() canvas-dimension fix, but the same “labels is undefined” error still occurs.

    Setting responsive: false suppresses the error, but the charts do not render because they are initialized inside hidden containers. So the master fix does not fully resolve this reproduction.

    The relevant chart configuration is:

    options: {
    resizeDelay: 250,
    plugins: {
    legend: {
    display: false
    },
    datalabels: {
    formatter: (value, ctx) => {
    let sum = 0;
    let dataArr = ctx.chart.data.datasets[0].data;
    dataArr.map(data => {
    sum += data;
    });
    let percentage = (value * 100 / sum).toFixed(0);
    return percentage == 0 ? null : percentage + '%';
    },
    borderRadius: 4,
    color: 'white',
    font: {
    weight: 'bold',
    size: 18
    },
    padding: 6
    }
    }
    }

    The responsive option is not explicitly set, so it uses the default value of true. The remaining issue may involve the chart lifecycle, hidden containers, or plugin initialization in this REDCap setup.

  8. HuzaifaChaudary commented on Sep 1, 2026

    @HuzaifaChaudary

    that config settles it, and it is a second bug rather than the one master fixed. thanks for testing against master, that is what ruled the first one out.

    resizeDelay: 250 plus a chart built inside a hidden container is enough on its own. the canvas fix is not involved.

    what happens

    the constructor only updates when the canvas is already in the document:

    this._initialize();
    if (this.attached) {
      this.update();
    }

    core.controller.js:191. for a canvas whose parent is not in the document yet, attached is false, so the first update() never runs and no plugin has had a beforeUpdate yet.

    the attach handler then does this, core.controller.js:1025:

    this.attached = true;
    this.resize();

    it sets the flag and resizes. it never updates. so _resize runs with attached now true and reaches core.controller.js:296:

    if (this.attached) {
      if (this._doResize(mode)) {
        // The resize update is delayed, only draw without updating.
        this.render();
      }
    }

    and debounce returns its delay, helpers.extras.ts:59:

    if (delay) {
      clearTimeout(timeout);
      timeout = setTimeout(fn, delay, args);
    } else {
      fn.apply(this, args);
    }
    return delay;

    with resizeDelay: 250 that returns 250, which is truthy, so render() runs now and the update is queued for 250ms later. with the default 0 it calls update inline and returns 0, so nothing renders early.

    so afterDatasetsDraw fires before any beforeUpdate has ever run. datalabels builds its label state in beforeUpdate, so it reads it undefined. any plugin that initialises per update state has the same exposure, datalabels is just the one you are using.

    measured

    a bare plugin recording hook order, chart built on a detached canvas then appended to the document, nothing else:

    resizeDelay   0   order  beforeUpdate|afterDatasetsDraw   drawIdx 1  updateIdx  0   update_first
    resizeDelay 250   order  afterDatasetsDraw                drawIdx 0  updateIdx -1   DREW_BEFORE_UPDATE
    

    chrome 152, chart.js master. updateIdx -1 means beforeUpdate never fired at all before the draw.

    that also explains the two things you noticed. responsive: false takes the other branch at core.controller.js:969 and sets attached = true directly, so the constructor does run the first update, which is why the error goes away, and it skips bindResponsiveEvents entirely, which is why nothing ever sizes to a container that was hidden. both follow from the same line.

    fixing it

    two shapes, and which one is right is a maintainer call rather than mine:

    1. have the attach path run an update instead of a bare resize, since attach is the first time the chart has a real size
    2. keep the early draw but suppress it until the first update has completed, so render() before any update() is not possible

    the second is narrower. i can put up a pull request for either, with the probe above turned into a proper test, if a maintainer says which they prefer. i have not guessed at it since it is a lifecycle change.

    worth noting #12293 is unrelated to this and still stands on its own, it only pins the canvas dimension regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions