You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Possible plugin lifecycle regression in Chart.js 4.5.1 causing chartjs-plugin-datalabels to draw before internal labels are prepared #12269
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.
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:
Dynamic (un)registration: if chartjs-plugin-datalabels (or your app) calls Chart.register(...)/Chart.unregister(...) on an already-constructed chart's plugin afterafterDestroy 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.
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.
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.
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 installreturn;}
_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:
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:
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:
does your config set resizeDelay, and does it still happen with it at 0
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.
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:
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:
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:
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.
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.
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.
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 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.
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:
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:
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:
have the attach path run an update instead of a bare resize, since attach is the first time the chart has a real size
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.
Activity
yfwmaniish commentedon Aug 25, 2026
Confirmed the diff you described is accurate --
src/core/core.plugins.jsbetween 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()callsnotifyPlugins('beforeInit')as its very first statement, beforeresize()/bindEvents()/afterInit, and every othernotify()call site I found (update,render,draw,destroy, etc.) all go through the sameChart.prototype.notifyPlugins→this._plugins.notify(this, hook, ...)path after construction completes. So for a chart constructed the normal way,beforeInitshould always run (and set_init) well before anydraw-adjacent hook could reach the new guard -- I couldn't find an ordinary path where adrawhook fires while_initis stillundefined.Two things that could still produce what you're seeing, worth checking if you get a chance to narrow the REDCap repro:
chartjs-plugin-datalabels(or your app) callsChart.register(...)/Chart.unregister(...)on an already-constructed chart's plugin afterafterDestroyhas run once (_initreset toundefinedthere) but before a subsequent full re-_initialize()on the sameChartinstance -- if anything in your app reuses aChartobject acrossdestroy()+ 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.chartjs-plugin-datalabelsitself callschart.notifyPlugins(...)or otherwise pokes_pluginsdirectly 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.
bubbajdf commentedon Aug 27, 2026
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.
HuzaifaChaudary commentedon Aug 29, 2026
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
_initchange does not explain it. the early return only fires when_initis undefined, andnotifysets_initon thebeforeInithook itself, one line above the check:_initialize()callsnotifyPlugins('beforeInit')first, so by the time anything else is notified_initis 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._doResizeis a debounce, anddebouncereturns the delay:with
resizeDelayat its default of 0 the debounce runsupdateinline and returns 0, so the render is skipped. with any non zeroresizeDelayit returns that number, which is truthy, sorender()runs straight away while the update is still pending. i measured the hooks on a responsive pie chart withresizeDelay: 50: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
afterDatasetsDrawto fire before the firstafterUpdateat construction. on my build the order comes out asbecause the resize inside
_initializehappens beforebindEvents, whenattachedis still falsy, so therender()branch is not taken. your trace has the resize coming frombindResponsiveEvents, which is after that point and before the constructors ownupdate(), so i think it needs the container to actually change size as the observer attaches.so, two questions that would settle it, @bubbajdf:
resizeDelay, and does it still happen with it at 0if 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
_layersis not the culprit either, it is initialised to[]in the constructor atcore.controller.js:154, so a draw before the first update does not throw on it. thelengthin your error is the plugins own array.HuzaifaChaudary commentedon Aug 30, 2026
i was partly wrong above and i would rather correct it than have someone dig in the wrong place.
the
resizeDelaywindow i described is real but it cannot be what changed in 4.5.1.src/core/core.controller.jsis byte identical between v4.5.0 and v4.5.1 so_resizeand_doResizeand therender()branch all behaved the same in both. only three runtime files changed in that release:the one that matters is
helpers.dom.ts. 4.5.1 swappedMath.floorforround1inretinaScaleand the canvas dimensions are written from that value directly:canvas.widthandcanvas.heightare 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:that return value is the early exit in
_resize: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
bindResponsiveEventsfires as the observer attaches now runs the whole body instead of returning early. that is exactly where your trace goes and it also explains whyresponsive: falseavoids it since that path skipsbindResponsiveEventsaltogether.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_resizeonly runs when_doResizereturns truthy and that needsresizeDelayto be non zero. with it at 0 the debounce callsupdateinline and returns 0 so the update happens before the draw. so if you do setresizeDelaythe 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.
bubbajdf commentedon Aug 31, 2026
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.
HuzaifaChaudary commentedon Aug 31, 2026
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.8stores 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_resizeonly runs when_doResizereturns truthy, and that needsresizeDelayto be non zero. atresizeDelay: 0the debounce callsupdateinline 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:
resizeDelaythen the chain is fully explained, master already fixes it through Use integer canvas dimensions #12142, and this can close once that shipswhich 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.
bubbajdf commentedon Aug 31, 2026
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.
HuzaifaChaudary commentedon Sep 1, 2026
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: 250plus 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:
core.controller.js:191. for a canvas whose parent is not in the document yet,attachedis false, so the firstupdate()never runs and no plugin has had abeforeUpdateyet.the attach handler then does this,
core.controller.js:1025:it sets the flag and resizes. it never updates. so
_resizeruns withattachednow true and reachescore.controller.js:296:and
debouncereturns its delay,helpers.extras.ts:59:with
resizeDelay: 250that returns 250, which is truthy, sorender()runs now and the update is queued for 250ms later. with the default 0 it callsupdateinline and returns 0, so nothing renders early.so
afterDatasetsDrawfires before anybeforeUpdatehas ever run. datalabels builds its label state inbeforeUpdate, 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:
chrome 152, chart.js master.
updateIdx -1meansbeforeUpdatenever fired at all before the draw.that also explains the two things you noticed.
responsive: falsetakes the other branch atcore.controller.js:969and setsattached = truedirectly, so the constructor does run the first update, which is why the error goes away, and it skipsbindResponsiveEventsentirely, 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:
render()before anyupdate()is not possiblethe 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.