Skip to content

Commit 95e77af

Browse files
committed
perf: replace deep watches with $watchCollection by default (#728)
1 parent 88382a8 commit 95e77af

5 files changed

Lines changed: 92 additions & 7 deletions

File tree

GEMINI.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ This is a legacy AngularJS 1.x wrapper for Chart.js. It has recently been modern
4343
- **Scale Configuration**: All examples have been updated to the v4 object-based scale syntax.
4444
- **Mixed Chart Transparency**: When creating mixed-type charts (e.g., Bar + Line), ensure that bar transparency is explicitly set in the `chart-dataset-override` using `backgroundColor` with an alpha channel (e.g., `rgba(69, 183, 205, 0.2)`). This prevents the library's default color logic from potentially applying a solid color over the intended transparent bar.
4545
- **Test-Driven Refactoring**: The `ChartJs` service is returned as a fresh object from `$get` instead of an object with getters. This ensures compatibility with mocking libraries like **Sinon**, which can have issues spying on or stubbing getter properties.
46+
- **Performance Optimization (Shallow Watches)**:
47+
- By default, directives now use `$watchCollection` (shallow) for data attributes to avoid the high overhead of recursive deep-equality checks on large datasets.
48+
- **Opt-in Deep Watch**: If deep mutations are required, users can set `chart-dataset-watch-deep="true"` on the directive or `datasetWatchDeep: true` globally via `ChartJsProvider`.
4649

4750
## Deployment & Versioning
4851
- **Trunk-Based**: Work happens on `main`. Current modernization is stabilized on `release/v3.0.0-rc.1`.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Here are the options for all directives:
7373
- `chart-plugins`: (default: `[]`): array of [Chart.js plugins](http://www.chartjs.org/docs/latest/developers/plugins.html)
7474
- `chart-display-when-no-data`: (default: `false`): whether to create the chart even if data is empty or undefined
7575
- `chart-force-update`: (default: `false`): whether to force a chart update even if data references have not changed
76+
- `chart-dataset-watch-deep`: (default: `false`): whether to use recursive deep watching ($watch with objectEquality: true) for data. By default, the library uses $watchCollection for better performance with large datasets.
7677

7778
There is another directive `chart-base` that takes an extra attribute `chart-type` to define the type
7879
dynamically.
@@ -101,6 +102,8 @@ angular.module("app", ["chart.js"])
101102
ChartJsProvider.setOptions({
102103
chartColors: ['#FF5252', '#FF8A80'],
103104
responsive: false,
105+
// Enable deep watching for all charts (performance cost on large datasets)
106+
datasetWatchDeep: false,
104107
});
105108
// Configure all line charts
106109
ChartJsProvider.setOptions('line', {

docs/MIGRATION.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ If you are using TypeScript, `angular-chart.js` now provides built-in types. Not
9696
- **Global `Chart`**: The library no longer attempts to auto-inject Chart.js; it expects the `Chart` class to be available in the environment.
9797
- **Encapsulated Defaults**: The library no longer mutates `Chart.defaults`. Opinionated defaults are now internal to `ChartJsProvider`. If you relied on `angular-chart.js` to configure other non-Angular charts globally, you must now configure them manually in `Chart.defaults`.
9898
- **Events**: Event arguments (like `points` in `chart-click`) now match the Chart.js 4.x `ActiveElement` structure.
99+
- **Watch Strategy (Performance)**:
100+
- **Default**: The library now uses `$watchCollection` (shallow watch) for `chart-data`, `chart-labels`, etc. This significantly improves performance for large datasets.
101+
- **Action Required**: If you were mutating nested arrays/objects *without* changing the top-level reference, the chart will no longer update automatically.
102+
- **Workaround**: Either update the array reference (preferred) or enable deep watching via `chart-dataset-watch-deep="true"` or `ChartJsProvider.setOptions({datasetWatchDeep: true})`.

src/angular-chart.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface ChartColor {
1313
export interface ChartJsProviderOptions extends Partial<ChartOptions> {
1414
chartAlpha?: number;
1515
chartFillAlpha?: number;
16+
datasetWatchDeep?: boolean;
1617
[key: string]: unknown;
1718
}
1819

@@ -41,6 +42,7 @@ export interface DirectiveScope extends angular.IScope {
4142
chartDatasetOverride?: Partial<ChartDataset> | Partial<ChartDataset>[];
4243
chartPlugins?: Plugin[];
4344
chartForceUpdate?: boolean;
45+
chartDatasetWatchDeep?: boolean;
4446
chartDisplayWhenNoData?: boolean;
4547
chart?: Chart;
4648
}
@@ -172,17 +174,22 @@ function ChartJsFactory(ChartJs: ChartJsService, $timeout: angular.ITimeoutServi
172174
chartPlugins: '=?',
173175
chartForceUpdate: '=?',
174176
chartDisplayWhenNoData: '=?',
177+
chartDatasetWatchDeep: '=?',
175178
},
176179
link: function(scope: DirectiveScope, elem: angular.IAugmentedJQuery/* , attrs */) {
180+
const deepWatch = scope.chartDatasetWatchDeep ?? ChartJs.getOptions().datasetWatchDeep ?? false;
181+
const watchFn = deepWatch ? '$watch' : '$watchCollection';
182+
const watchEquality = deepWatch ? true : undefined;
183+
177184
// Order of setting "watch" matter
178-
scope.$watch('chartData', watchDataUpdate, true);
179-
scope.$watch('chartSeries', watchRecreation, true);
180-
scope.$watch('chartLabels', watchRecreation, true);
181-
scope.$watch('chartOptions', watchOptions, true);
182-
scope.$watch('chartColors', watchRecreation, true);
183-
scope.$watch('chartDatasetOverride', watchRecreation, true);
185+
(scope as any)[watchFn]('chartData', watchDataUpdate, watchEquality);
186+
(scope as any)[watchFn]('chartSeries', watchRecreation, watchEquality);
187+
(scope as any)[watchFn]('chartLabels', watchRecreation, watchEquality);
188+
(scope as any)[watchFn]('chartOptions', watchOptions, watchEquality);
189+
(scope as any)[watchFn]('chartColors', watchRecreation, watchEquality);
190+
(scope as any)[watchFn]('chartDatasetOverride', watchRecreation, watchEquality);
184191
scope.$watch('chartType', watchType, false);
185-
scope.$watch('chartPlugins', watchRecreation, true);
192+
(scope as any)[watchFn]('chartPlugins', watchRecreation, watchEquality);
186193
scope.$watch('chartDisplayWhenNoData', (newVal, oldVal) => {
187194
if (newVal === oldVal) {
188195
return;

test/test.unit.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,74 @@ describe('Unit testing', function() {
659659
expect(updated).to.be.true;
660660
});
661661
});
662+
663+
describe('chart-dataset-watch-deep (#728)', function() {
664+
it('does not update chart on deep data mutations by default', function() {
665+
const markup = '<canvas class="chart chart-line" chart-data="data" ' +
666+
'chart-labels="labels"></canvas>';
667+
scope.labels = ['Monday'];
668+
scope.data = [[1]];
669+
670+
$compile(markup)(scope);
671+
scope.$digest();
672+
673+
let updated = false;
674+
scope.$on('chart-update', function() {
675+
updated = true;
676+
});
677+
678+
// Deep mutation
679+
(scope.data as number[][])[0][0] = 2;
680+
scope.$digest();
681+
682+
expect(updated).to.be.false;
683+
});
684+
685+
it('updates chart on deep data mutations when chart-dataset-watch-deep is true', function() {
686+
const markup = '<canvas class="chart chart-line" chart-data="data" ' +
687+
'chart-labels="labels" chart-dataset-watch-deep="true"></canvas>';
688+
scope.labels = ['Monday'];
689+
scope.data = [[1]];
690+
691+
$compile(markup)(scope);
692+
scope.$digest();
693+
694+
let updated = false;
695+
scope.$on('chart-update', function() {
696+
updated = true;
697+
});
698+
699+
// Deep mutation
700+
(scope.data as number[][])[0][0] = 2;
701+
scope.$digest();
702+
703+
expect(updated).to.be.true;
704+
});
705+
706+
it('updates chart on deep data mutations when datasetWatchDeep is true globally', function() {
707+
ChartJsProvider.setOptions({datasetWatchDeep: true});
708+
const markup = '<canvas class="chart chart-line" chart-data="data" ' +
709+
'chart-labels="labels"></canvas>';
710+
scope.labels = ['Monday'];
711+
scope.data = [[1]];
712+
713+
$compile(markup)(scope);
714+
scope.$digest();
715+
716+
let updated = false;
717+
scope.$on('chart-update', function() {
718+
updated = true;
719+
});
720+
721+
// Deep mutation
722+
(scope.data as number[][])[0][0] = 2;
723+
scope.$digest();
724+
725+
expect(updated).to.be.true;
726+
// Reset global option for other tests
727+
ChartJsProvider.setOptions({datasetWatchDeep: false});
728+
});
729+
});
662730
});
663731
});
664732
});

0 commit comments

Comments
 (0)