Skip to content

Stepper spec never exercises content projection, so step-content instantiation is untested #5710

Description

@nabramovitz

steppers.component.spec.ts covers the stepper's onEnter delivery, but it does so without ever rendering a stepper. The relevant spec builds its steps by hand:

const stepA = Object.assign(new StepComponent(), { title: 'A' });
const stepB = Object.assign(new StepComponent(), { title: 'B' });
(component as any).allSteps = [stepA, stepB];
(component as any).filterSteps();

No host template, no <app-step>, no projected content, no ngTemplateOutlet. So the spec exercises the bookkeeping in setActive and nothing about how step content is actually instantiated.

That gap let an incorrect claim sit in the source for a while. The comment above the afterNextRender call in setActive used to read:

the entering step's content is only instantiated by the ngTemplateOutlet on currentIndex during that render

That isn't what happens. <ng-content> inside an <ng-template> is not lazily instantiated in Ivy — the projected content is constructed with the declaring view, regardless of which step is active. A throwaway probe confirms it: a directive with an @Input inside a nested @if, in a step that is never activated, still has its ngOnChanges run on first render.

@Directive({ selector: '[probeInput]', standalone: true })
class ProbeInputDirective implements OnChanges {
  @Input() probeInput!: string;
  ngOnChanges(): void { log.push('ngOnChanges:' + this.probeInput); }
}

@Component({
  selector: 'probe-b',
  standalone: true,
  imports: [ProbeInputDirective],
  template: `@if (outer) { @if (inner) { <span [probeInput]="'ctx'"></span> } }`,
})
class ProbeBComponent { outer = true; inner = true; }

@Component({
  standalone: true,
  imports: [SteppersComponent, StepComponent, ProbeAComponent, ProbeBComponent],
  template: `
    <app-steppers>
      <app-step [title]="'One'"><probe-a></probe-a></app-step>
      <app-step [title]="'Two'"><probe-b></probe-b></app-step>
    </app-steppers>
  `,
})
class HostComponent { }

With only step One active, the log is ['ctor:A', 'ctor:B', 'ngOnChanges:ctx'] — step Two's content is constructed and change-detected on first render.

This is not academic. It is the mechanism behind a bug found in review on #5707: a directive living in the wizard's step 2 reset shared service state on its first ngOnChanges, on the assumption that a non-active step's content had not been created yet. It had. The comment has since been corrected there, but the spec that should have caught the misconception still doesn't.

Worth adding a spec that mounts a real host with projected step content and pins the two behaviours that actually matter:

  1. A non-active step's content is constructed and change-detected on first render (so anyone reasoning about @Input/ngOnChanges timing in a step gets a failing test if this ever changes).
  2. onEnter is still delivered after the activating render, which is what the current spec means to assert — but asserted through a real stepper rather than hand-built StepComponent objects.

The existing hand-built spec can stay as a unit-level check of setActive; the point is that it should not be the only coverage, because it cannot fail for the reason the comment claimed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions