Skip to content

SSR outputs <!--container--> instead of HTML as per template syntax #60871

@davidbusuttil

Description

@davidbusuttil

Which @angular/* package(s) are the source of the bug?

Don't known / other

Is this a regression?

No

Description

Introduction

I am using Firestore to store data for a CMS.

The data is stored in a field called blocks.

The CMS is divided into two components: Block and Inline.

Block Component

block.component.ts

import { ChangeDetectionStrategy, Component, input, ViewEncapsulation } from "@angular/core";
import { NgClass } from "@angular/common";

import { InlineComponent } from "../inline/inline.component";

import { JoinArrayPipe } from "../../pipes/join-array/join-array.pipe";
import { BlockFlowPipe } from "../../pipes/block-flow/block-flow.pipe";
import { BlockFlowRootPipe } from "../../pipes/block-flow-root/block-flow-root.pipe";
import { SectionPipe } from "../../pipes/section/section.pipe";
import { ArticlePipe } from "../../pipes/article/article.pipe";
import { AsidePipe } from "../../pipes/aside/aside.pipe";
import { MeshPipe } from "../../pipes/mesh/mesh.pipe";
import { HeadingPipe } from "../../pipes/heading/heading.pipe";
import { ParagraphPipe } from "../../pipes/paragraph/paragraph.pipe";
import { AddressPipe } from "../../pipes/address/address.pipe";
import { ListPipe } from "../../pipes/list/list.pipe";
import { MenuPipe } from "../../pipes/menu/menu.pipe";
import { DescriptionPipe } from "../../pipes/description/description.pipe";
import { SeparatorPipe } from "../../pipes/separator/separator.pipe";

import { Block } from "../../types/block/block";

@Component({
  selector: "[contentBlock]",
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [
    NgClass,
    InlineComponent,
    JoinArrayPipe,
    BlockFlowPipe,
    BlockFlowRootPipe,
    SectionPipe,
    ArticlePipe,
    AsidePipe,
    MeshPipe,
    HeadingPipe,
    ParagraphPipe,
    AddressPipe,
    ListPipe,
    MenuPipe,
    DescriptionPipe,
    SeparatorPipe,
  ],
  templateUrl: "./block.component.html",
})
export class BlockComponent {
  readonly blocks = input.required<Block[]>({ alias: "contentBlock" });
}

block.component.html

@for (block of blocks(); track $index) {
  @switch (block["@type"]) {
    @case ("block-flow") {
      @let blockFlow = block | blockFlow;
      <div
        class="block-flow"
        [ngClass]="blockFlow.ngClass"
        [attr.id]="blockFlow.id"
        [attr.lang]="blockFlow.lang"
        [attr.title]="blockFlow.title"
        [contentBlock]="blockFlow.blocks"
      ></div>
    }
    @case ("block-flow-root") {
      @let blockFlowRoot = block | blockFlowRoot;
      <div
        class="block-flow"
        [ngClass]="blockFlowRoot.ngClass"
        [attr.id]="blockFlowRoot.id"
        [attr.lang]="blockFlowRoot.lang"
        [attr.title]="blockFlowRoot.title"
        [contentBlock]="blockFlowRoot.blocks"
      ></div>
    }
    @case ("section") {
      @let section = block | section;
      <section
        class="section"
        [ngClass]="section.ngClass"
        [attr.id]="section.id"
        [attr.lang]="section.lang"
        [attr.title]="section.title"
        [contentBlock]="section.blocks"
      ></section>
    }
    @case ("article") {
      @let article = block | article;
      <article
        class="article"
        [ngClass]="article.ngClass"
        [attr.id]="article.id"
        [attr.lang]="article.lang"
        [attr.title]="article.title"
        [contentBlock]="article.blocks"
      ></article>
    }
    @case ("aside") {
      @let aside = block | aside;
      <aside
        class="aside"
        [ngClass]="aside.ngClass"
        [attr.id]="aside.id"
        [attr.lang]="aside.lang"
        [attr.title]="aside.title"
        [contentBlock]="aside.blocks"
      ></aside>
    }
    @case ("mesh") {
      @let mesh = block | mesh;
      <div class="mesh" [ngClass]="mesh.ngClass" [attr.id]="mesh.id" [attr.lang]="mesh.lang" [attr.title]="mesh.title">
        @for (meshItem of mesh.meshItems; track $index) {
          <div
            class="mesh-item"
            [ngClass]="meshItem.ngClass"
            [attr.id]="meshItem.id"
            [attr.lang]="meshItem.lang"
            [attr.title]="meshItem.title"
            [contentBlock]="meshItem.blocks"
          ></div>
        }
      </div>
    }
    @case ("heading") {
      @let heading = block | heading;
      @switch (heading.level) {
        @case (1) {
          <h1
            class="heading"
            [ngClass]="['x-small:size:medium', 'small:size:large', 'medium:size:x-large'] | joinArray: heading.ngClass"
            [attr.id]="heading.id"
            [attr.lang]="heading.lang"
            [attr.title]="heading.title"
            [contentInline]="heading.inlines"
          ></h1>
        }
        @case (2) {
          <h2
            class="heading"
            [ngClass]="['x-small:size:small', 'small:size:medium', 'medium:size:large'] | joinArray: heading.ngClass"
            [attr.id]="heading.id"
            [attr.lang]="heading.lang"
            [attr.title]="heading.title"
            [contentInline]="heading.inlines"
          ></h2>
        }
        @case (3) {
          <h3
            class="heading"
            [ngClass]="['x-small:size:x-small', 'small:size:small', 'medium:size:medium'] | joinArray: heading.ngClass"
            [attr.id]="heading.id"
            [attr.lang]="heading.lang"
            [attr.title]="heading.title"
            [contentInline]="heading.inlines"
          ></h3>
        }
      }
    }
    @case ("paragraph") {
      @let paragraph = block | paragraph;
      <p
        class="paragraph"
        [ngClass]="['x-small:size:medium', 'small:size:large', 'medium:size:x-large'] | joinArray: paragraph.ngClass"
        [attr.id]="paragraph.id"
        [attr.lang]="paragraph.lang"
        [attr.title]="paragraph.title"
        [contentInline]="paragraph.inlines"
      ></p>
    }
    @case ("address") {
      @let address = block | address;
      <p
        class="address"
        [ngClass]="['x-small:size:medium', 'small:size:large', 'medium:size:x-large'] | joinArray: address.ngClass"
        [attr.id]="address.id"
        [attr.lang]="address.lang"
        [attr.title]="address.title"
        [contentInline]="address.inlines"
      ></p>
    }
    @case ("list") {
      @let list = block | list;
      @switch (list.type) {
        @case ("ordered") {
          <ol
            class="list"
            [ngClass]="
              ['x-small:size:medium', 'small:size:large', 'medium:size:x-large', 'x-small:type:ordered']
                | joinArray: list.ngClass
            "
            [attr.id]="list.id"
            [attr.lang]="list.lang"
            [attr.title]="list.title"
          >
            @for (listItem of list.listItems; track $index) {
              <li
                class="list-item"
                [ngClass]="listItem.ngClass"
                [attr.id]="listItem.id"
                [attr.lang]="listItem.lang"
                [attr.title]="listItem.title"
                [contentInline]="listItem.inlines"
              ></li>
            }
          </ol>
        }
        @case ("unordered") {
          <ul
            class="list"
            [ngClass]="
              ['x-small:size:medium', 'small:size:large', 'medium:size:x-large', 'x-small:type:unordered']
                | joinArray: list.ngClass
            "
            [attr.id]="list.id"
            [attr.lang]="list.lang"
            [attr.title]="list.title"
          >
            @for (listItem of list.listItems; track $index) {
              <li
                class="list-item"
                [ngClass]="listItem.ngClass"
                [attr.id]="listItem.id"
                [attr.lang]="listItem.lang"
                [attr.title]="listItem.title"
                [contentInline]="listItem.inlines"
              ></li>
            }
          </ul>
        }
      }
    }
    @case ("menu") {
      @let menu = block | menu;
      <ul
        class="menu"
        [ngClass]="['x-small:size:medium', 'small:size:large', 'medium:size:x-large'] | joinArray: menu.ngClass"
        [attr.id]="menu.id"
        [attr.lang]="menu.lang"
        [attr.title]="menu.title"
      >
        @for (menuItem of menu.menuItems; track $index) {
          <li
            class="menu-item"
            [ngClass]="menuItem.ngClass"
            [attr.id]="menuItem.id"
            [attr.lang]="menuItem.lang"
            [attr.title]="menuItem.title"
            [contentInline]="menuItem.inlines"
          ></li>
        }
      </ul>
    }
    @case ("description") {
      @let description = block | description;
      <dl
        class="description"
        [ngClass]="['x-small:size:medium', 'small:size:large', 'medium:size:x-large'] | joinArray: description.ngClass"
        [attr.id]="description.id"
        [attr.lang]="description.lang"
        [attr.title]="description.title"
      >
        @for (descriptionItem of description.descriptionItems; track $index) {
          @switch (descriptionItem.type) {
            @case ("term") {
              <dt
                class="description-term"
                [ngClass]="descriptionItem.ngClass"
                [attr.id]="descriptionItem.id"
                [attr.lang]="descriptionItem.lang"
                [attr.title]="descriptionItem.title"
                [contentInline]="descriptionItem.inlines"
              ></dt>
            }
            @case ("detail") {
              <dd
                class="description-detail"
                [ngClass]="descriptionItem.ngClass"
                [attr.id]="descriptionItem.id"
                [attr.lang]="descriptionItem.lang"
                [attr.title]="descriptionItem.title"
                [contentInline]="descriptionItem.inlines"
              ></dd>
            }
          }
        }
      </dl>
    }
    @case ("separator") {
      @let separator = block | separator;
      <hr class="separator" [ngClass]="separator.ngClass" />
    }
  }
}
<ng-content />

Inline Component

inline.component.ts

import { ChangeDetectionStrategy, Component, input, ViewEncapsulation } from "@angular/core";
import { NgClass, NgOptimizedImage } from "@angular/common";
import { RouterLink } from "@angular/router";

import { JoinArrayPipe } from "../../pipes/join-array/join-array.pipe";
import { InlineFlowPipe } from "../../pipes/inline-flow/inline-flow.pipe";
import { InlineFlowRootPipe } from "../../pipes/inline-flow-root/inline-flow-root.pipe";
import { HiddenPipe } from "../../pipes/hidden/hidden.pipe";
import { StrongPipe } from "../../pipes/strong/strong.pipe";
import { EmphasisPipe } from "../../pipes/emphasis/emphasis.pipe";
import { AbbreviationPipe } from "../../pipes/abbreviation/abbreviation.pipe";
import { TimePipe } from "../../pipes/time/time.pipe";
import { SubscriptPipe } from "../../pipes/subscript/subscript.pipe";
import { SuperscriptPipe } from "../../pipes/superscript/superscript.pipe";
import { LinkPipe } from "../../pipes/link/link.pipe";
import { AnchorPipe } from "../../pipes/anchor/anchor.pipe";
import { ButtonPipe } from "../../pipes/button/button.pipe";
import { ImagePipe } from "../../pipes/image/image.pipe";
import { LogoPipe } from "../../pipes/logo/logo.pipe";
import { IconPipe } from "../../pipes/icon/icon.pipe";
import { LinebreakPipe } from "../../pipes/linebreak/linebreak.pipe";

import { Inline } from "../../types/inline/inline";

@Component({
  selector: "[contentInline]",
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [
    NgClass,
    NgOptimizedImage,
    RouterLink,
    JoinArrayPipe,
    InlineFlowPipe,
    InlineFlowRootPipe,
    HiddenPipe,
    StrongPipe,
    EmphasisPipe,
    AbbreviationPipe,
    TimePipe,
    SubscriptPipe,
    SuperscriptPipe,
    LinkPipe,
    AnchorPipe,
    ButtonPipe,
    ImagePipe,
    LogoPipe,
    IconPipe,
    LinebreakPipe,
  ],
  templateUrl: "./inline.component.html",
})
export class InlineComponent {
  readonly inlines = input.required<Inline[]>({ alias: "contentInline" });
}

inline.component.html

@for (inline of inlines(); track $index) {
  @switch (inline["@type"]) {
    @case ("inline-flow") {
      @let inlineFlow = inline | inlineFlow;
      <span
        class="inline-flow"
        [ngClass]="inlineFlow.ngClass"
        [attr.id]="inlineFlow.id"
        [attr.lang]="inlineFlow.lang"
        [attr.title]="inlineFlow.title"
        [contentInline]="inlineFlow.inlines ?? []"
        >{{ inlineFlow.text }}</span
      >
    }
    @case ("inline-flow-root") {
      @let inlineFlowRoot = inline | inlineFlowRoot;
      <span
        class="inline-flow-root"
        [ngClass]="inlineFlowRoot.ngClass"
        [attr.id]="inlineFlowRoot.id"
        [attr.lang]="inlineFlowRoot.lang"
        [attr.title]="inlineFlowRoot.title"
        [contentInline]="inlineFlowRoot.inlines ?? []"
        >{{ inlineFlowRoot.text }}</span
      >
    }
    @case ("hidden") {
      @let hidden = inline | hidden;
      <span
        class="hidden"
        [ngClass]="hidden.ngClass"
        [attr.id]="hidden.id"
        [attr.lang]="hidden.lang"
        [attr.title]="hidden.title"
        [contentInline]="hidden.inlines ?? []"
        >{{ hidden.text }}</span
      >
    }
    @case ("strong") {
      @let strong = inline | strong;
      <strong
        class="strong"
        [ngClass]="strong.ngClass"
        [attr.id]="strong.id"
        [attr.lang]="strong.lang"
        [attr.title]="strong.title"
        [contentInline]="strong.inlines ?? []"
        >{{ strong.text }}</strong
      >
    }
    @case ("emphasis") {
      @let emphasis = inline | emphasis;
      <em
        class="emphasis"
        [ngClass]="emphasis.ngClass"
        [attr.id]="emphasis.id"
        [attr.lang]="emphasis.lang"
        [attr.title]="emphasis.title"
        [contentInline]="emphasis.inlines ?? []"
        >{{ emphasis.text }}</em
      >
    }
    @case ("abbreviation") {
      @let abbreviation = inline | abbreviation;
      <abbr
        class="abbreviation"
        [ngClass]="abbreviation.ngClass"
        [attr.id]="abbreviation.id"
        [attr.lang]="abbreviation.lang"
        [attr.title]="abbreviation.title"
        [contentInline]="abbreviation.inlines ?? []"
        >{{ abbreviation.text }}</abbr
      >
    }
    @case ("time") {
      @let time = inline | time;
      <time
        class="time"
        [ngClass]="time.ngClass"
        [attr.dateTime]="time.dateTime"
        [attr.id]="time.id"
        [attr.lang]="time.lang"
        [attr.title]="time.title"
        [contentInline]="time.inlines ?? []"
        >{{ time.text }}</time
      >
    }
    @case ("subscript") {
      @let subscript = inline | subscript;
      <sub
        class="subscript"
        [ngClass]="subscript.ngClass"
        [attr.id]="subscript.id"
        [attr.lang]="subscript.lang"
        [attr.title]="subscript.title"
        [contentInline]="subscript.inlines ?? []"
        >{{ subscript.text }}</sub
      >
    }
    @case ("superscript") {
      @let superscript = inline | superscript;
      <sup
        class="superscript"
        [ngClass]="superscript.ngClass"
        [attr.id]="superscript.id"
        [attr.lang]="superscript.lang"
        [attr.title]="superscript.title"
        [contentInline]="superscript.inlines ?? []"
        >{{ superscript.text }}</sup
      >
    }
    @case ("link") {
      @let link = inline | link;
      @switch (link.type) {
        @case ("router") {
          <a
            class="link"
            [ngClass]="['x-small:color:primary'] | joinArray: link.ngClass"
            [routerLink]="link.routerLink"
            [fragment]="link.fragment"
            [queryParams]="link.queryParams"
            [queryParamsHandling]="link.queryParamsHandling"
            [attr.id]="link.id"
            [attr.lang]="link.lang"
            [attr.title]="link.title"
            [contentInline]="link.inlines ?? []"
            >{{ link.text }}</a
          >
        }
        @case ("href") {
          <a
            class="link"
            [ngClass]="['x-small:color:primary'] | joinArray: link.ngClass"
            [attr.href]="link.href"
            [attr.target]="link.target"
            [attr.id]="link.id"
            [attr.lang]="link.lang"
            [attr.title]="link.title"
            [contentInline]="link.inlines ?? []"
            >{{ link.text }}</a
          >
        }
      }
    }
    @case ("anchor") {
      @let anchor = inline | anchor;
      @switch (anchor.type) {
        @case ("router") {
          <a
            class="anchor"
            [ngClass]="['x-small:color:primary'] | joinArray: anchor.ngClass"
            [routerLink]="anchor.routerLink"
            [fragment]="anchor.fragment"
            [queryParams]="anchor.queryParams"
            [queryParamsHandling]="anchor.queryParamsHandling"
            [attr.id]="anchor.id"
            [attr.lang]="anchor.lang"
            [attr.title]="anchor.title"
            [contentInline]="anchor.inlines ?? []"
            >{{ anchor.text }}</a
          >
        }
        @case ("href") {
          <a
            class="anchor"
            [ngClass]="['x-small:color:primary'] | joinArray: anchor.ngClass"
            [attr.href]="anchor.href"
            [attr.target]="anchor.target"
            [attr.id]="anchor.id"
            [attr.lang]="anchor.lang"
            [attr.title]="anchor.title"
            [contentInline]="anchor.inlines ?? []"
            >{{ anchor.text }}</a
          >
        }
      }
    }
    @case ("button") {
      @let button = inline | button;
      @switch (button.type) {
        @case ("router") {
          <a
            class="button"
            [ngClass]="['x-small:color:2-inverse', 'x-small:background-color:primary'] | joinArray: button.ngClass"
            [routerLink]="button.routerLink"
            [fragment]="button.fragment"
            [queryParams]="button.queryParams"
            [queryParamsHandling]="button.queryParamsHandling"
            [attr.id]="button.id"
            [attr.lang]="button.lang"
            [attr.title]="button.title"
            [contentInline]="button.inlines ?? []"
            >{{ button.text }}</a
          >
        }
        @case ("href") {
          <a
            class="button"
            [ngClass]="['x-small:color:2-inverse', 'x-small:background-color:primary'] | joinArray: button.ngClass"
            [attr.href]="button.href"
            [attr.target]="button.target"
            [attr.id]="button.id"
            [attr.lang]="button.lang"
            [attr.title]="button.title"
            [contentInline]="button.inlines ?? []"
            >{{ button.text }}</a
          >
        }
      }
    }
    @case ("image") {
      @let image = inline | image;
      <img
        class="image"
        [ngClass]="image.ngClass"
        [ngSrc]="image.ngSrc"
        [width]="image.width"
        [height]="image.height"
        [priority]="image.priority"
        [placeholder]="image.placeholder"
        [attr.alt]="image.alt"
        [attr.id]="image.id"
        [attr.lang]="image.lang"
        [attr.title]="image.title"
      />
    }
    @case ("logo") {
      @let logo = inline | logo;
      <svg
        class="logo"
        [ngClass]="logo.ngClass"
        [attr.viewBox]="logo.viewBox"
        [attr.xmlns]="'http://www.w3.org/2000/svg'"
        [attr.aria-hidden]="true"
      >
        @for (logoItem of logo.logoItems; track $index) {
          <path [attr.d]="logoItem.d" [attr.fill]="logoItem.fill" />
        }
      </svg>
    }
    @case ("icon") {
      @let icon = inline | icon;
      <svg
        class="icon"
        [ngClass]="icon.ngClass"
        [attr.viewBox]="icon.viewBox"
        [attr.xmlns]="'http://www.w3.org/2000/svg'"
        [attr.aria-hidden]="true"
      >
        @for (iconItem of icon.iconItems; track $index) {
          <path [attr.d]="iconItem.d" [attr.fill]="iconItem.fill" />
        }
      </svg>
    }
    @case ("linebreak") {
      @let linebreak = inline | linebreak;
      <br class="linebreak" [ngClass]="linebreak.ngClass" />
    }
  }
}
<ng-content />

Generating the Content

When the blocks are retrieved from Firestore, they are simply loaded as follows (example):

<article class="article" [contentBlock]="document.blocks"></article>

The Issue

After a number of iterations, SSR stops rendering the HTML, and instead, it outputs a <!--container--> instead, which is simply used for hydration.

What Should Happen

SSR should wait until all HTML is rendered and avoid outputing <!--container--> as this is not good for SEO.

Please provide a link to a minimal reproduction of the bug

No response

Please provide the exception or error you saw


Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 19.2.7
Node: 22.14.0
Package Manager: npm 10.9.2
OS: win32 x64

Angular: 19.2.6
... common, compiler, compiler-cli, core, platform-browser
... platform-browser-dynamic, platform-server, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1902.7
@angular-devkit/build-angular   19.2.7
@angular-devkit/core            19.2.7
@angular-devkit/schematics      19.2.7
@angular/cli                    19.2.7
@angular/fire                   19.1.0
@angular/ssr                    19.2.7
@schematics/angular             19.2.7
rxjs                            7.8.2
typescript                      5.8.3

Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: commonIssues related to APIs in the @angular/common packagearea: serverIssues related to server-side renderingcommon: image directiveneeds reproductionThis issue needs a reproduction in order for the team to investigate further

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions