From d46c590efe441ec2d98b10427ebb813d2899f559 Mon Sep 17 00:00:00 2001 From: Jian Zhang <53095992+Jian-Zhang08@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:30:45 -0500 Subject: [PATCH 1/3] fix: prevent Gantt crash when zoomScale is configured without scales _sortScales accessed timelineHeader.scales.length without checking that scales was defined. When only zoomScale is configured (and the zoom scale manager has not populated scales yet), timelineHeader.scales is undefined, so opening the Gantt threw. Guard the access with timelineHeader?.scales. Closes #5159 --- ...59-gantt-sort-scales_2026-06-26-12-00.json | 11 +++++++ .../__tests__/gantt-sort-scales.test.ts | 31 +++++++++++++++++++ packages/vtable-gantt/src/Gantt.ts | 5 ++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json create mode 100644 packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts diff --git a/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json new file mode 100644 index 000000000..f7c9a9e25 --- /dev/null +++ b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable-gantt", + "comment": "Fix a crash in the Gantt component when `zoomScale` is configured without `scales`; `_sortScales` accessed `timelineHeader.scales.length` without a guard (GitHub #5159)", + "type": "patch" + } + ], + "packageName": "@visactor/vtable-gantt", + "email": "53095992+Jian-Zhang08@users.noreply.github.com" +} diff --git a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts new file mode 100644 index 000000000..86258461b --- /dev/null +++ b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts @@ -0,0 +1,31 @@ +// @ts-nocheck + +global.__VERSION__ = 'none'; + +import { Gantt } from '../src/index'; + +describe('Gantt._sortScales', () => { + // Regression test for https://github.com/VisActor/VTable/issues/5159 + // When only `zoomScale` is configured (so `timelineHeader.scales` is still + // undefined), `_sortScales` used to crash on `timelineScales.length`. + test('does not throw when timelineHeader.scales is undefined', () => { + const context = { + options: { timelineHeader: {} }, + parsedOptions: {} + }; + + expect(() => Gantt.prototype._sortScales.call(context)).not.toThrow(); + }); + + test('still sorts the configured scales', () => { + const context = { + options: { timelineHeader: { scales: [{ unit: 'day' }, { unit: 'month' }] } }, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(context); + + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day', 'month']); + }); +}); diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index 4084a9713..5492fd58f 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -689,7 +689,10 @@ export class Gantt extends EventTarget { _sortScales() { const { timelineHeader } = this.options; - if (timelineHeader) { + // `scales` can be undefined when only `zoomScale` is configured (the zoom + // scale manager may not have populated it yet), so guard against it instead + // of crashing on `timelineScales.length`. + if (timelineHeader?.scales) { const timelineScales = timelineHeader.scales; const sortOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; if (timelineScales.length === 1) { From 36b5ed0b0b0c968a2f548b39b6d589d6d75d7084 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 14 Jul 2026 17:20:23 +0800 Subject: [PATCH 2/3] fix: initialize gantt timeline scales for zoomScale --- .../__tests__/gantt-sort-scales.test.ts | 38 ++++++++ packages/vtable-gantt/src/Gantt.ts | 90 ++++++++++--------- 2 files changed, 88 insertions(+), 40 deletions(-) diff --git a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts index 86258461b..9fc9f832c 100644 --- a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts +++ b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts @@ -15,6 +15,8 @@ describe('Gantt._sortScales', () => { }; expect(() => Gantt.prototype._sortScales.call(context)).not.toThrow(); + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day']); }); test('still sorts the configured scales', () => { @@ -28,4 +30,40 @@ describe('Gantt._sortScales', () => { expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['month', 'day']); expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day', 'month']); }); + + test('uses zoomScale current level when timelineHeader.scales is undefined', () => { + const context = { + options: { + timelineHeader: { + zoomScale: { + enabled: true, + levels: [ + [ + { unit: 'month', step: 1 }, + { unit: 'day', step: 1 } + ] + ] + } + } + }, + zoomScaleManager: { + config: { + levels: [ + [ + { unit: 'month', step: 1 }, + { unit: 'day', step: 1 } + ] + ] + }, + getCurrentLevel: () => 0 + }, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(context); + + expect(context.options.timelineHeader.scales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.sortedTimelineScales.map(scale => scale.unit)).toEqual(['month', 'day']); + expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day', 'month']); + }); }); diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index 5492fd58f..348ce92f4 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -84,6 +84,9 @@ export function createRootElement(padding: any, className: string = 'vtable-gant return element; } + +const DEFAULT_TIMELINE_SCALE: ITimelineScale = { unit: 'day', step: 1 }; + export class Gantt extends EventTarget { options: GanttConstructorOptions; container: HTMLElement; @@ -689,48 +692,55 @@ export class Gantt extends EventTarget { _sortScales() { const { timelineHeader } = this.options; - // `scales` can be undefined when only `zoomScale` is configured (the zoom - // scale manager may not have populated it yet), so guard against it instead - // of crashing on `timelineScales.length`. - if (timelineHeader?.scales) { - const timelineScales = timelineHeader.scales; - const sortOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; - if (timelineScales.length === 1) { - if ( - timelineScales[0].unit === 'hour' || - timelineScales[0].unit === 'minute' || - timelineScales[0].unit === 'second' - ) { - this.parsedOptions.timeScaleIncludeHour = true; - } + const zoomLevelScales = this.zoomScaleManager?.config.levels[this.zoomScaleManager.getCurrentLevel()]; + const timelineScales = + timelineHeader?.scales?.length > 0 + ? timelineHeader.scales + : zoomLevelScales?.length > 0 + ? zoomLevelScales + : [DEFAULT_TIMELINE_SCALE]; + + if (timelineHeader && (!timelineHeader.scales || timelineHeader.scales.length === 0)) { + timelineHeader.scales = timelineScales.map(scale => ({ ...scale })); + } + + const sortOrder = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second']; + this.parsedOptions.timeScaleIncludeHour = false; + if (timelineScales.length === 1) { + if ( + timelineScales[0].unit === 'hour' || + timelineScales[0].unit === 'minute' || + timelineScales[0].unit === 'second' + ) { + this.parsedOptions.timeScaleIncludeHour = true; } - const orderedScales = timelineScales.slice().sort((a, b) => { - if (a.unit === 'hour' || a.unit === 'minute' || a.unit === 'second') { - this.parsedOptions.timeScaleIncludeHour = true; - } - const indexA = sortOrder.indexOf(a.unit); - const indexB = sortOrder.indexOf(b.unit); - if (indexA === -1) { - return 1; - } else if (indexB === -1) { - return -1; - } - return indexA - indexB; - }); - const reverseOrderedScales = timelineScales.slice().sort((a, b) => { - const indexA = sortOrder.indexOf(a.unit); - const indexB = sortOrder.indexOf(b.unit); - if (indexA === -1) { - return 1; - } else if (indexB === -1) { - return -1; - } - return indexB - indexA; - }); - - this.parsedOptions.sortedTimelineScales = orderedScales; - this.parsedOptions.reverseSortedTimelineScales = reverseOrderedScales; } + const orderedScales = timelineScales.slice().sort((a, b) => { + if (a.unit === 'hour' || a.unit === 'minute' || a.unit === 'second') { + this.parsedOptions.timeScaleIncludeHour = true; + } + const indexA = sortOrder.indexOf(a.unit); + const indexB = sortOrder.indexOf(b.unit); + if (indexA === -1) { + return 1; + } else if (indexB === -1) { + return -1; + } + return indexA - indexB; + }); + const reverseOrderedScales = timelineScales.slice().sort((a, b) => { + const indexA = sortOrder.indexOf(a.unit); + const indexB = sortOrder.indexOf(b.unit); + if (indexA === -1) { + return 1; + } else if (indexB === -1) { + return -1; + } + return indexB - indexA; + }); + + this.parsedOptions.sortedTimelineScales = orderedScales; + this.parsedOptions.reverseSortedTimelineScales = reverseOrderedScales; } _generateTimeLineDateMap() { From 5831da3072d04ac48f00b12e76957df6d37c2300 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 15 Jul 2026 11:28:06 +0800 Subject: [PATCH 3/3] fix: avoid shared default gantt timeline scale --- ...5159-gantt-sort-scales_2026-06-26-12-00.json | 2 +- .../__tests__/gantt-sort-scales.test.ts | 17 +++++++++++++++++ packages/vtable-gantt/src/Gantt.ts | 15 +++++++++------ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json index f7c9a9e25..7f44ce2b8 100644 --- a/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json +++ b/common/changes/@visactor/vtable-gantt/fix-issue-5159-gantt-sort-scales_2026-06-26-12-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@visactor/vtable-gantt", - "comment": "Fix a crash in the Gantt component when `zoomScale` is configured without `scales`; `_sortScales` accessed `timelineHeader.scales.length` without a guard (GitHub #5159)", + "comment": "Fix a crash in the Gantt component when `zoomScale` is configured without `timelineHeader.scales`, and initialize timeline scales from the active zoom level or a safe default (GitHub #5159)", "type": "patch" } ], diff --git a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts index 9fc9f832c..ee14e82c5 100644 --- a/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts +++ b/packages/vtable-gantt/__tests__/gantt-sort-scales.test.ts @@ -19,6 +19,23 @@ describe('Gantt._sortScales', () => { expect(context.parsedOptions.reverseSortedTimelineScales.map(scale => scale.unit)).toEqual(['day']); }); + test('does not share mutable default scale objects across calls', () => { + const firstContext = { + options: {}, + parsedOptions: {} + }; + const secondContext = { + options: {}, + parsedOptions: {} + }; + + Gantt.prototype._sortScales.call(firstContext); + firstContext.parsedOptions.sortedTimelineScales[0].timelineDates = ['stale']; + Gantt.prototype._sortScales.call(secondContext); + + expect(secondContext.parsedOptions.sortedTimelineScales[0].timelineDates).toBeUndefined(); + }); + test('still sorts the configured scales', () => { const context = { options: { timelineHeader: { scales: [{ unit: 'day' }, { unit: 'month' }] } }, diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index 348ce92f4..bf77edc65 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -693,12 +693,15 @@ export class Gantt extends EventTarget { _sortScales() { const { timelineHeader } = this.options; const zoomLevelScales = this.zoomScaleManager?.config.levels[this.zoomScaleManager.getCurrentLevel()]; - const timelineScales = - timelineHeader?.scales?.length > 0 - ? timelineHeader.scales - : zoomLevelScales?.length > 0 - ? zoomLevelScales - : [DEFAULT_TIMELINE_SCALE]; + const defaultTimelineScale: ITimelineScale = { ...DEFAULT_TIMELINE_SCALE }; + let timelineScales: ITimelineScale[]; + if (timelineHeader?.scales?.length > 0) { + timelineScales = timelineHeader.scales; + } else if (zoomLevelScales?.length > 0) { + timelineScales = zoomLevelScales; + } else { + timelineScales = [defaultTimelineScale]; + } if (timelineHeader && (!timelineHeader.scales || timelineHeader.scales.length === 0)) { timelineHeader.scales = timelineScales.map(scale => ({ ...scale }));