diff --git a/.changeset/tricky-rivers-stand.md b/.changeset/tricky-rivers-stand.md new file mode 100644 index 000000000..8e0757f14 --- /dev/null +++ b/.changeset/tricky-rivers-stand.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: validate dates and tick interval to prevent UI freeze/crash in gantt diagramtype diff --git a/cypress/integration/rendering/gantt.spec.js b/cypress/integration/rendering/gantt.spec.js index 72cb6ea29..f250a188f 100644 --- a/cypress/integration/rendering/gantt.spec.js +++ b/cypress/integration/rendering/gantt.spec.js @@ -833,4 +833,34 @@ describe('Gantt diagram', () => { {} ); }); + it('should handle seconds-only format with tickInterval (issue #5496)', () => { + imgSnapshotTest( + ` + gantt + tickInterval 1second + dateFormat ss + axisFormat %s + + section Network Request + RTT : rtt, 0, 20 + `, + {} + ); + }); + it('should handle dates with year typo like 202 instead of 2024 (issue #5496)', () => { + imgSnapshotTest( + ` + gantt + title Schedule + dateFormat YYYY-MM-DD + tickInterval 1week + axisFormat %m-%d + + section Vacation + London : 2024-12-01, 7d + London : 202-12-01, 7d + `, + {} + ); + }); }); diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.js b/packages/mermaid/src/diagrams/gantt/ganttDb.js index b1b2052c1..7faf357f2 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.js +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.js @@ -268,7 +268,15 @@ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, include const getStartDate = function (prevTime, dateFormat, str) { str = str.trim(); - if ((dateFormat.trim() === 'x' || dateFormat.trim() === 'X') && /^\d+$/.test(str)) { + + // Helper function to check if format is a timestamp format (x or X) + const isTimestampFormat = (format) => { + const trimmedFormat = format.trim(); + return trimmedFormat === 'x' || trimmedFormat === 'X'; + }; + + // Handle timestamp formats (x, X) with numeric strings + if (isTimestampFormat(dateFormat) && /^\d+$/.test(str)) { return new Date(Number(str)); } // Test for after @@ -293,13 +301,15 @@ const getStartDate = function (prevTime, dateFormat, str) { return today; } - // Check for actual date set + // Check for actual date set using dayjs strict parsing let mDate = dayjs(str, dateFormat.trim(), true); if (mDate.isValid()) { return mDate.toDate(); } else { log.debug('Invalid date:' + str); log.debug('With date format:' + dateFormat.trim()); + + // Timestamp formats can fall back to new Date() const d = new Date(str); if ( d === undefined || diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts index d2d38e86d..48d618938 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts @@ -505,4 +505,27 @@ describe('when using the ganttDb', function () { ganttDb.addTask('test1', 'id1,202304,1d'); expect(() => ganttDb.getTasks()).toThrowError('Invalid date:202304'); }); + + it('should handle seconds-only format with valid numeric values (issue #5496)', function () { + ganttDb.setDateFormat('ss'); + ganttDb.addSection('Network Request'); + ganttDb.addTask('RTT', 'rtt, 0, 20'); + const tasks = ganttDb.getTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0].task).toBe('RTT'); + expect(tasks[0].id).toBe('rtt'); + }); + + it('should handle dates with year typo like 202 instead of 2024 (issue #5496)', function () { + ganttDb.setDateFormat('YYYY-MM-DD'); + ganttDb.addSection('Vacation'); + ganttDb.addTask('London Trip 1', '2024-12-01, 7d'); + ganttDb.addTask('London Trip 2', '202-12-01, 7d'); + const tasks = ganttDb.getTasks(); + expect(tasks).toHaveLength(2); + // First task should be in year 2024 + expect(tasks[0].startTime.getFullYear()).toBe(2024); + // Second task will be parsed as year 202 (fallback to new Date()) + expect(tasks[1].startTime.getFullYear()).toBe(202); + }); }); diff --git a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js index 9f899a40f..7a30e0d4a 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js +++ b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js @@ -1,4 +1,5 @@ import dayjs from 'dayjs'; +import dayjsDuration from 'dayjs/plugin/duration.js'; import { log } from '../../logger.js'; import { select, @@ -28,6 +29,8 @@ import common from '../common/common.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; import { configureSvgSize } from '../../setupGraphViewbox.js'; +dayjs.extend(dayjsDuration); + export const setConf = function () { log.debug('Something is calling, setConf, remove the call'); }; @@ -78,6 +81,7 @@ const getMaxIntersections = (tasks, orderOffset) => { }; let w; +const MAX_TICK_COUNT = 10000; export const draw = function (text, id, version, diagObj) { const conf = getConfig().gantt; @@ -602,6 +606,27 @@ export const draw = function (text, id, version, diagObj) { .attr('class', 'exclude-range'); } + /** + * Calculates the estimated number of ticks based on the time domain and tick interval. + * Returns the estimated number of ticks as a number. + * @param {Date} minTime - The minimum time in the domain + * @param {Date} maxTime - The maximum time in the domain + * @param {number} every - The interval count (e.g., 1 for "1second") + * @param {string} interval - The interval unit (e.g., "second", "day") + * @returns {number} The estimated number of ticks + */ + function getEstimatedTickCount(minTime, maxTime, every, interval) { + if (every <= 0 || minTime > maxTime) { + return Infinity; + } + const timeDiffMs = maxTime - minTime; + const intervalMs = dayjs.duration({ [interval ?? 'day']: every }).asMilliseconds(); + if (intervalMs <= 0) { + return Infinity; + } + return Math.ceil(timeDiffMs / intervalMs); + } + /** * @param theSidePad * @param theTopPad @@ -630,32 +655,54 @@ export const draw = function (text, id, version, diagObj) { ); if (resultTickInterval !== null) { - const every = resultTickInterval[1]; - const interval = resultTickInterval[2]; - const weekday = diagObj.db.getWeekday() || conf.weekday; + const every = parseInt(resultTickInterval[1], 10); + if (isNaN(every) || every <= 0) { + log.warn( + `Invalid tick interval value: "${resultTickInterval[1]}". Skipping custom tick interval.` + ); + // Skip applying custom ticks + } else { + const interval = resultTickInterval[2]; + const weekday = diagObj.db.getWeekday() || conf.weekday; - switch (interval) { - case 'millisecond': - bottomXAxis.ticks(timeMillisecond.every(every)); - break; - case 'second': - bottomXAxis.ticks(timeSecond.every(every)); - break; - case 'minute': - bottomXAxis.ticks(timeMinute.every(every)); - break; - case 'hour': - bottomXAxis.ticks(timeHour.every(every)); - break; - case 'day': - bottomXAxis.ticks(timeDay.every(every)); - break; - case 'week': - bottomXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every)); - break; - case 'month': - bottomXAxis.ticks(timeMonth.every(every)); - break; + // Get the time domain to check tick count + const domain = timeScale.domain(); + const minTime = domain[0]; + const maxTime = domain[1]; + const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval); + + if (estimatedTicks > MAX_TICK_COUNT) { + log.warn( + `The tick interval "${every}${interval}" would generate ${estimatedTicks} ticks, ` + + `which exceeds the maximum allowed (${MAX_TICK_COUNT}). ` + + `This may indicate an invalid date or time range. Skipping custom tick interval.` + ); + // D3 will use its default automatic tick generation + } else { + switch (interval) { + case 'millisecond': + bottomXAxis.ticks(timeMillisecond.every(every)); + break; + case 'second': + bottomXAxis.ticks(timeSecond.every(every)); + break; + case 'minute': + bottomXAxis.ticks(timeMinute.every(every)); + break; + case 'hour': + bottomXAxis.ticks(timeHour.every(every)); + break; + case 'day': + bottomXAxis.ticks(timeDay.every(every)); + break; + case 'week': + bottomXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every)); + break; + case 'month': + bottomXAxis.ticks(timeMonth.every(every)); + break; + } + } } } @@ -677,32 +724,48 @@ export const draw = function (text, id, version, diagObj) { .tickFormat(timeFormat(axisFormat)); if (resultTickInterval !== null) { - const every = resultTickInterval[1]; - const interval = resultTickInterval[2]; - const weekday = diagObj.db.getWeekday() || conf.weekday; + const every = parseInt(resultTickInterval[1], 10); + if (isNaN(every) || every <= 0) { + log.warn( + `Invalid tick interval value: "${resultTickInterval[1]}". Skipping custom tick interval.` + ); + // Skip applying custom ticks + } else { + const interval = resultTickInterval[2]; + const weekday = diagObj.db.getWeekday() || conf.weekday; - switch (interval) { - case 'millisecond': - topXAxis.ticks(timeMillisecond.every(every)); - break; - case 'second': - topXAxis.ticks(timeSecond.every(every)); - break; - case 'minute': - topXAxis.ticks(timeMinute.every(every)); - break; - case 'hour': - topXAxis.ticks(timeHour.every(every)); - break; - case 'day': - topXAxis.ticks(timeDay.every(every)); - break; - case 'week': - topXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every)); - break; - case 'month': - topXAxis.ticks(timeMonth.every(every)); - break; + // Get the time domain to check tick count + const domain = timeScale.domain(); + const minTime = domain[0]; + const maxTime = domain[1]; + const estimatedTicks = getEstimatedTickCount(minTime, maxTime, every, interval); + + // Only apply custom ticks if the count is reasonable + if (estimatedTicks <= MAX_TICK_COUNT) { + switch (interval) { + case 'millisecond': + topXAxis.ticks(timeMillisecond.every(every)); + break; + case 'second': + topXAxis.ticks(timeSecond.every(every)); + break; + case 'minute': + topXAxis.ticks(timeMinute.every(every)); + break; + case 'hour': + topXAxis.ticks(timeHour.every(every)); + break; + case 'day': + topXAxis.ticks(timeDay.every(every)); + break; + case 'week': + topXAxis.ticks(mapWeekdayToTimeFunction[weekday].every(every)); + break; + case 'month': + topXAxis.ticks(timeMonth.every(every)); + break; + } + } } }