How the marketing preview loads a layout

PR 10849 touched three files in marketing-page-full-preview. The sizes are in the other notes. This page is the code. I am walking it in the order a public page hits it.

commit 3645b6d5ee PR 10849 25 Sep 2026

What used to sit in the preview file

The old marketing-page-full-preview.component.ts imported 54 layout classes at the top and listed all 54 in imports. The template then *ngSwitched on section.layoutId. Angular will not render a tag you did not import, so the switch forced the whole catalog into the same JavaScript as the preview.

A page with a header, a hero, and a footer still paid for passion-grid. That is the leftover this PR kills. The editor, add-marketing-page-section, still has the static list. Leave it. A creator opening the picker needs every type.

The three files

  • marketing-page-layout-loaders.ts is new. A map from layout id to import(), plus the extra inputs some layouts already had.
  • marketing-page-full-preview.component.ts dropped the 54 class imports. It resolves colors the same way, then loads the ids this page actually shows.
  • marketing-page-full-preview.component.html kept the header markup. Everything else goes through ngComponentOutlet.

The map has to spell every path

The bundler only splits a dynamic import when the path is a string literal. A clever import(\`.../${id}/...\`) looks nicer and ships nothing as its own file. So the map is 53 boring entries. One of them looks like this.

[LAYOUT_IDS.HERO_LAYOUT]: () =>
  import('../marketing-page-layout-container/hero-layout/hero-layout.component').then(
    (m) => m.HeroLayoutComponent
  ),

The function does not run until something calls it. Putting the map in a file the preview imports does not download hero. Calling MARKETING_PAGE_LAYOUT_LOADERS['hero-layout']() does.

Header is not in the map. STATIC_MARKETING_PAGE_LAYOUT_IDS is a one-item set, HEADER_LAYOUT. pro-contact-form-layout is not in the map either. That id never had a preview class in the old 54. The pro page still owns that form itself.

Inputs that used to live on the switch cases

Most layouts only take data. A few took more, and those bindings used to sit on the matching *ngSwitchCase. They moved into getMarketingPageLayoutInputs so the outlet can pass one object.

const inputs = { data: ctx.data };

if (layoutId === LAYOUT_IDS.PINNED_HORIZONTAL_SCROLL_LAYOUT) {
  inputs['disablePinnedScroll'] = isMobilePreview;
}
if (CMS_PREVIEW_EDITING_LAYOUT_IDS.has(layoutId)) {
  inputs['isEditingMode'] = ctx.cmsPreviewMode;
}
if (layoutId === LAYOUT_IDS.FOOTER_LAYOUT) {
  inputs['syncWhitelabelLogo'] = ctx.syncWhitelabelLogo;
  inputs['previewBranding'] = ctx.previewBranding;
}

The CMS set is the layouts that already accepted isEditingMode in the old template. Snap-scroll also gets isEditingMode when the cover preview is in the mobile frame. Carousel and the two comparison tables get forceMobileView in that frame. I did not invent those flags. I copied the old cases.

The preview now imports four things

imports: [
  CommonModule,
  HeaderLayoutComponent,
  HeaderUserMenuComponent,
  CustomWelcomeModalTemplate
]

That is the whole static graph of the preview. Hero is gone. Footer is gone. Comparison table is gone. Header stays because the template still writes <app-header-layout> by hand.

layoutComponents is the cache. Key is a layout id. Value is the class that came back from import(). Once hero is in there, a later section with the same id does not fetch again. A second marketing URL in the same visit can reuse the chunk the browser already has.

Resolve colors, then load

resolveSectionsOnce is the old color pass. Theme vars, cover-page color mode, the heading-highlight flag. That did not change. What changed is the last two lines. Each resolved section now carries layoutInputs, and then we start the imports.

layoutInputs: this.buildLayoutInputs(item.section.layoutId, resolvedData)
// ...
this.cdr.markForCheck();
void this.loadVisibleLayoutComponents();

void is there because the method is async and we are not awaiting it from ngOnInit. The wait lives inside the method, on PendingTasks. If you await from the lifecycle hook instead and skip the task list, SSR still finishes early. Angular does not treat a dangling promise on ngOnInit as work it must finish. It treats PendingTasks that way.

ngOnChanges rebuilds the whole list when sections arrive after init, or when colors change. If only the CMS preview flags change, it rebuilds layoutInputs and leaves the cached classes alone. That is the cheap path. Switching the cover preview from desktop to mobile should not reload hero.

Load only what this page shows

const layoutIds = [
  ...new Set(
    this.resolvedSections
      .filter((item) => item.section.isVisible !== false)
      .map((item) => item.section.layoutId)
      .filter((layoutId) =>
        !!layoutId &&
        !STATIC_MARKETING_PAGE_LAYOUT_IDS.has(layoutId) &&
        !!MARKETING_PAGE_LAYOUT_LOADERS[layoutId] &&
        !this.layoutComponents[layoutId]
      )
  )
];

Hidden sections stay off the wire. Header never goes through the map. An unknown id is skipped, not thrown. Already-cached ids are skipped. Set so two heroes on one page fetch once.

Then the part that keeps prerender honest.

const generation = ++this.layoutLoadGeneration;
const done = this.pendingTasks.add();
try {
  const loaded = await Promise.all(
    layoutIds.map(async (layoutId) => {
      const layoutComponent = await MARKETING_PAGE_LAYOUT_LOADERS[layoutId]();
      return [layoutId, layoutComponent];
    })
  );
  if (generation !== this.layoutLoadGeneration) {
    return;
  }
  for (const [layoutId, layoutComponent] of loaded) {
    this.layoutComponents[layoutId] = layoutComponent;
  }
  this.cdr.markForCheck();
} finally {
  done();
}

Promise.all starts every missing layout together. Sequential import() would make SSR wait for the sum of the chunk times. Parallel waits for the slowest one.

pendingTasks.add() tells Angular the page is not ready. done() in finally tells it we finished, including when an import throws. Skip that and the first HTML has no <app-hero-layout>. We already saw this on white-label home when Profile used a fire-and-forget import(). 172 KB, no <app-profile>. The task list brought it back.

The generation number is the same idea as the camera-modal race. Sections change, or the component dies, and an older Promise.all must not write into layoutComponents. ngOnDestroy bumps the number. A stale load still calls done(), so SSR does not hang, but it does not paint dead classes.

@defer is the wrong tool here. It is client-only. The server HTML would be empty wrappers. Search engines and the first paint would see a hole, then the browser would fill it. That is the prerender you do not want on a public marketing page.

Header stays a real tag

<ng-container *ngIf="item.section.layoutId === LAYOUT_IDS.HEADER_LAYOUT; else lazySectionLayout">
  <app-header-layout #headerLayout ...>
    <ng-template #coverUserMenuTemplate>
      <app-header-user-menu ...></app-header-user-menu>
    </ng-template>
    <app-welcome-video-modal
      headerLayoutWelcomeVideo
      [post]="headerLayout.welcomeVideoPost"
      (canceled)="headerLayout.onWelcomeVideoModalClosed()"
    ></app-welcome-video-modal>
  </app-header-layout>
</ng-container>

<ng-template #lazySectionLayout>
  <ng-container
    [ngComponentOutlet]="layoutComponents[item.section.layoutId]"
    [ngComponentOutletInputs]="item.layoutInputs"
  ></ng-container>
</ng-template>

The header block is the old template. It projects a user-menu template and a welcome-video modal. The modal reads headerLayout.welcomeVideoPost and calls back into the header when it closes. ngComponentOutlet can pass inputs. It cannot project content, and it cannot give you a template variable pointing at the created instance for a sibling to bind to. Moving the welcome modal into HeaderLayout would fix that. That is a different PR, and I would not hold this one for it.

When the outlet's class is still undefined, Angular renders nothing for that section. That is why the load has to finish before SSR serializes. After the cache fill, markForCheck makes OnPush notice the new classes.

What this file does not decide

SEO tags come from setMarketingPageSeo on the page component, from the published payload. They never imported a layout class. Title and description stay the same whether hero is static or lazy.

The four promotional route files still import each other. That is why home is 1.76 MB after this change. The preview no longer owns that leftover. promotional.routes.ts does.