Fix infinite recursion in iterator loop traversal when If/Else branch loops back to enclosing iterator (#19714)

Fix a stack overflow (Maximum call stack size exceeded) in
getAllStepIdsInLoop caused by an If/Else branch inside an iterator loop
pointing back to the enclosing iterator. The traversal incorrectly
treated the enclosing iterator as a nested iterator, calling
getAllStepIdsInLoop recursively with fresh visited sets, causing
infinite recursion.

Add the enclosing iterator's own ID to the skip condition in
traverseSteps so back-edges from If/Else branches are handled the same
way as back-edges from regular nextStepIds.

<img width="1054" height="723" alt="Capture d’écran 2026-04-15 à 11 00
42"
src="https://github.com/user-attachments/assets/aee1477b-5059-4552-809e-7c8a34a9ec4a"
/>
This commit is contained in:
Thomas Trompette
2026-04-15 11:23:25 +02:00
committed by GitHub
parent f953aea3c5
commit 46ee72160d
2 changed files with 111 additions and 1 deletions
@@ -207,6 +207,116 @@ describe('getAllStepIdsInLoop', () => {
});
});
describe('if-else branch looping back to enclosing iterator', () => {
it('should not stack overflow when an if-else branch points back to the enclosing iterator', () => {
const steps = [
createMockIteratorStep('iterator1', [], ['step1']),
createMockCodeStep('step1', ['ifElse1']),
createMockIfElseStep('ifElse1', [
{
id: 'branch-if',
filterGroupId: 'fg1',
nextStepIds: ['stepA'],
},
{ id: 'branch-else', nextStepIds: ['iterator1'] },
]),
createMockCodeStep('stepA', ['iterator1']),
];
const result = getAllStepIdsInLoop({
iteratorStepId: 'iterator1',
initialLoopStepIds: ['step1'],
steps,
});
expect(result).toEqual(
expect.arrayContaining(['step1', 'ifElse1', 'stepA']),
);
expect(result).toHaveLength(3);
expect(result).not.toContain('iterator1');
});
it('should handle multiple if-else branches where some loop back to iterator and others continue', () => {
const steps = [
createMockIteratorStep('iterator1', ['exitStep'], ['step1']),
createMockCodeStep('step1', ['step2']),
createMockCodeStep('step2', ['ifElse1']),
createMockIfElseStep('ifElse1', [
{
id: 'branch-skip',
filterGroupId: 'fg1',
nextStepIds: ['iterator1'],
},
{
id: 'branch-create',
filterGroupId: 'fg2',
nextStepIds: ['stepCreate'],
},
{ id: 'branch-default', nextStepIds: [] },
]),
createMockCodeStep('stepCreate', ['stepSave']),
createMockCodeStep('stepSave', ['iterator1']),
createMockCodeStep('exitStep', []),
];
const result = getAllStepIdsInLoop({
iteratorStepId: 'iterator1',
initialLoopStepIds: ['step1'],
steps,
});
expect(result).toEqual(
expect.arrayContaining([
'step1',
'step2',
'ifElse1',
'stepCreate',
'stepSave',
]),
);
expect(result).toHaveLength(5);
expect(result).not.toContain('iterator1');
expect(result).not.toContain('exitStep');
});
it('should handle nested iterator inside if-else branch that also has a branch looping back to outer iterator', () => {
const steps = [
createMockIteratorStep('outerIterator', ['exitStep'], ['ifElse1']),
createMockIfElseStep('ifElse1', [
{
id: 'branch-nested',
filterGroupId: 'fg1',
nextStepIds: ['innerIterator'],
},
{
id: 'branch-skip',
nextStepIds: ['outerIterator'],
},
]),
createMockIteratorStep(
'innerIterator',
['outerIterator'],
['innerStep'],
),
createMockCodeStep('innerStep', ['innerIterator']),
createMockCodeStep('exitStep', []),
];
const result = getAllStepIdsInLoop({
iteratorStepId: 'outerIterator',
initialLoopStepIds: ['ifElse1'],
steps,
});
expect(result).toEqual(
expect.arrayContaining(['ifElse1', 'innerIterator', 'innerStep']),
);
expect(result).toHaveLength(3);
expect(result).not.toContain('outerIterator');
expect(result).not.toContain('exitStep');
});
});
describe('edge cases', () => {
it('should handle empty initial loop step IDs', () => {
const steps = [createMockIteratorStep('iterator1', ['step2'], [])];
@@ -17,7 +17,7 @@ const traverseSteps = ({
allStepIdsInLoop: Set<string>;
}) => {
for (const stepId of stepIds) {
if (visitedStepIds.has(stepId)) {
if (visitedStepIds.has(stepId) || stepId === iteratorStepId) {
continue;
}