TTMSFNCGanttChart 1.3.3.7 — stale internal task index: export lists the wrong tasks

Delphi 13.1 Florence, TMS FNC Gantt Chart 1.3.3.7.

After any change to the task structure, the export resolves its rows through an internal index that is never rebuilt. Tasks are then skipped, repeated, or — in the worst case — every row shows the same task. The chart on screen stays correct and the row count stays correct, so the output looks plausible.

Reproduced in your own PDF Export demo

demos\PDF Export, unit UDPDFExportDemo.pas, one line added in SaveBtnClick right before the export:

TMSFNCGanttChart1.Project.InsertTask('1', 'Inserted', '');
PDFIO.Save('.\export.pdf');

The demo's tasks come from the form file, so ReadState calls CalculateTasks at start-up and the index is valid — export-without-insert.pdf is correct. Adding the line above produces export-with-insert.pdf:

# expected exported
1 1 Inserted 1 Inserted
2 2 Assessment 2.1 Files Review
3 2.1 Files Review 3.2 Slice and Code
4 2.2 Map Site 4.3 Testing
5 3 Design 2 Assessment
6 3.1 Mockups 3.1 Mockups
7 3.2 Slice and Code 4.2 Scripts
8 4 Development 2 Assessment
9 4.1 Page Templates 3.1 Mockups
10 4.2 Scripts 4.2 Scripts
11 4.3 Testing 2 Assessment

Map Site, Design, Development and Page Templates are missing entirely; Assessment appears three times. (The inserted task has no start date, which is why that PDF has eight pages — that is my one-liner, not the issue.)

In our own application, after an editing session that included deletions, all 64 rows showed the first task and no bars or dependency lines were drawn at all.

Cause

  1. DrawExportTaskList resolves every row through Project.GetTaskByCount(I) (VCL.TMSFNCGanttChart.pas, line 8969); DrawExportTimeLine does the same for the bars (9399) and the dependency lines (9282).
  2. GetTaskByCount walks the project with GetNextTask, which works exclusively on the internal string list FTaskList (VCL.TMSFNCGanttClasses.pas, line 4686 ff.):
   idx := FTaskList.IndexOfName(ATask.WBS);
   if idx < FTaskList.Count - 1 then
     Result := (FTaskList.Objects[idx + 1] as TTMSFNCGanttTask);

Once the WBS numbers have shifted, the lookup finds the wrong entry — the table above — or fails completely, in which case idx is -1 and the function returns Objects[0], the first task, for every call.

  1. FTaskList is filled only in UpdateTaskList (line 5898), whose only call site in the whole package is CalculateTasks (line 4309).
  2. EndUpdate calls CalculateTasks only for work-time changes (line 4626 ff.):
   if FModified or FTasksModified or FWorkTimeModified then
   begin
     if FWorkTimeModified then
       CalculateTasks;

FTasksModified is evaluated in that very if, but never rebuilds the index. Nor does the insert itself: TTMSFNCGanttTasks.InsertTask calls inherited Insert and DoInternalTaskAdded, and ProjectTaskAdded only calls UpdateGanttListItems.

Deleted tasks additionally leave pointers to freed objects in FTaskList, which GetNextTask then casts with as TTMSFNCGanttTask. The run described above also produced three first-chance access violations (c0000005) in the print path; I have no call stack for those, but they never occurred with the workaround below in place.

The screen is unaffected because the task list draws from its own node tree, and GetTasksCount (line 4895) counts the real tree — which is why the row count stays right while the content is wrong.

Calling CalculateTasks first is not the answer

It does make the symptom go away, but:

  • It contradicts your own documentation. TTMSFNCGanttChart.GetNextTask is documented as "Retrieves the next task in the WBS order" — WBS order is exactly what fails. And the XMLDoc of TTMSFNCGanttProject.GetNextTask in the shipped sources promises "The next task, or nil when no matching task exists", while the implementation returns Objects[0]. GetTaskByCount carries the same promise.
  • No precondition is documented anywhere — not on those methods, and not in the TTMSFNCGanttChartPrintIO / TTMSFNCGanttChartPDFIO documentation, which never mentions CalculateTasks. UpdateTaskList appears in no manual at all; it is protected. CalculateTasks is documented as "Recalculates task dates, durations, and dependencies" — nothing about a lookup index. EndUpdate is documented as "Forces to calculate and update all of the settings after setting multiple properties."
  • It has side effects on the data. CalculateTasks recalculates the times of every task and can move dates. A printout must not modify the plan — which is why our workaround uses UpdateTaskList instead.

Not only the export

  • GetNextTask / GetPreviousTask (lines 5587 / 5681)
  • SelectNextTask / SelectPreviousTask (6906 / 6918) — select the wrong task
  • GetTaskCountByTask (GanttClasses 4646) — returns a wrong index

GetNextVisibleTask / GetPreviousVisibleTask (5595 / 5688) are not affected; they walk FTimeline.Items.

Suggested fix

In TTMSFNCGanttProject.EndUpdate:

if FTasksModified then
  UpdateTaskList;

Only the index — deliberately not the full CalculateTasks, so no task times are recalculated and no dates moved as a side effect.

At the very least, please make GetNextTask behave as documented and return nil when IndexOfName fails, instead of Objects[0]. That alone turns silently wrong output into something a caller can detect.

Workaround for anyone hitting this

type
  TProjectHack = class(TTMSFNCGanttProject);
...
TProjectHack(GanttChart.Project).UpdateTaskList;   // right before the export

Both frameworks

The FMX sources are affected identically: same GetNextTask, same single UpdateTaskList call site in CalculateTasks (line 4311), same EndUpdate, and the three GetTaskByCount call sites in FMX.TMSFNCGanttChart.pas sit at the same lines 8969, 9282 and 9399. The demo used above is the FMX one.

Line numbers refer to the sources shipped with 1.3.3.7.

export-with-insert.pdf (216.1 KB)

export-without-insert.pdf (167.1 KB)

We did some changes to the updating mechanism.
Can you check if this is fixed for you with the release of yesterday?

Thanks for looking into it so quickly.

I re-ran the exact repro from my first post against 1.3.3.8: your PDF Export
demo with

TMSFNCGanttChart1.Project.InsertTask('1', 'Inserted', '');

added in SaveBtnClick right before PDFIO.Save. The exported task list is now
correct — all eleven rows in WBS order, nothing skipped, nothing repeated
(PDF attached). Fixed, thank you.

In our own application the printout is correct as well: after inserting,
moving and deleting tasks, a 65-page export shows a continuous WBS column.
That test is less conclusive though, because we still call UpdateTaskList
ourselves before exporting — the demo above is the clean one.

I also read the implementation: the rebuild is now driven by
FTaskListModified, evaluated at the top of GetNextTask/GetPreviousTask, and
the new idx >= 0 guards make a failed lookup return nil as documented. That
covers GetTaskByCount too, since it starts with GetNextTask(nil). Both points
from my report are addressed.

One question while you are in that code: TTMSFNCGanttTask.SetWBSTaskPart
assigns the field without setting FTaskListModified. Changing WBSTaskPart
changes what GetWBS returns for that task and therefore invalidates the
index, but nothing marks it. We clear WBSTaskPart on all tasks right after
loading a project (to keep the numbering positional), which is exactly such a
case. I have not been able to produce a failure from it — in our application
nothing sorts (for the time being), so nothing freezes WBSTaskPart in the first
place — so please treat this as a reading of the code, not a bug report.
Would it make sense for that setter to mark the list as modified?

Thank you, this is indeed a good catch and should also set as modified.
The next update will have this solved as well.