htmlangularprimengaccordionlazy-evaluation

Lazily evaluating angular templates in primeng accordion tabs


I have a slightly expensive endpoint, which I want to get called only when it is actually required. For this question, I created a dummy service instead:

import { Injectable, Signal, signal } from '@angular/core';
import { delay, Observable, of, shareReplay, tap } from 'rxjs';

export enum Style {
  OLD_DIRECT,
  OLD_TEMPLATE,
  NEW_DIRECT,
  NEW_DEFER,
}

function style_str(style: Style) : string
{
  switch (style) {
    case Style.OLD_DIRECT:
      return 'old style, direct child';
      break;
    case Style.OLD_TEMPLATE:
      return 'old style w/ template';
      break;
    case Style.NEW_DIRECT:
      return 'new style';
      break;
    case Style.NEW_DEFER:
      return 'new style + defer block';
      break;
  }
}

@Injectable()
export class BackendService {
  log = signal('');

  public get_log(): Signal<string> {
    return this.log;
  }
  public get_thing(style: Style, index: number): Observable<string> {
    const style_text = style_str(style);
    const index_text = index == 1 ? 'first' : 'second';
    const message = `${index_text} tab (${style_text})`;
    return of(message).pipe(
      tap(() =>
        this.log.update(
          (l) => l + new Date().toLocaleTimeString() + ' ' + message + '\n'
        )
      ),
      delay(1000),
      shareReplay()
    );
  }
}

The focus of this question is not about this service, the service is only included to provide a working example. The key point is that this service returns an initially cold observable that only gets "hot" as soon as someone subscribes to it. The result is then cached using shareReplay. For demonstration purposes, the serices has a log that shows that requests were intiated at what time.

Then, I have a component that provides a couple of these still cold observables to the template:

import { Component, inject } from '@angular/core';
import { BackendService, Style } from './backend.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styles: '',
})
export class AppComponent {
  backend = inject(BackendService);
  // Some observables that perform an operation as soon as they
  // are subscribed (inspired by observables returned by http.get)
  old_direct$s = [
    this.backend.get_thing(Style.OLD_DIRECT, 1),
    this.backend.get_thing(Style.OLD_DIRECT, 2),
  ];
  old_template$s = [
    this.backend.get_thing(Style.OLD_TEMPLATE, 1),
    this.backend.get_thing(Style.OLD_TEMPLATE, 2),
  ];
  new_direct$s = [
    this.backend.get_thing(Style.NEW_DIRECT, 1),
    this.backend.get_thing(Style.NEW_DIRECT, 2),
  ];
  new_defer$s = [
    this.backend.get_thing(Style.NEW_DEFER, 1),
    this.backend.get_thing(Style.NEW_DEFER, 2),
  ];
}

The values emitted by these observables are intially hidden inside a closed accordion, and as soon as the user opens one of the accordion tabs, the respective observable is supposed to be subscribed. Following the old guidelines for using the Accordion in PrimeNG, this solution worked:

<h2>Using p-accordionTab with a template</h2>
<p-accordion>
  <p-accordionTab *ngFor="let text$ of old_template$s; index as idx" header="Tab {{idx+1}}">
    <ng-template pTemplate="content">
      {{ text$ | async }}
    </ng-template>
  </p-accordionTab>
</p-accordion>

The content that is visible once you open the accordion is stored in an <ng-template> marked with the PrimeNG tag content and only instantiated into the DOM once the accordion is opened. This is the point in time the request to the backend gets fired and this is what I want.

Recently, p-accordionTab got deprecated, and a <ng-template>-less syntax for accordion tabs has been introduced:

<h2>Using p-accordion-panel</h2>
<p-accordion>
  <p-accordion-panel *ngFor="let text$ of new_direct$s; index as idx" value="{{idx}}">
    <p-accordion-header>Tab {{idx+1}}</p-accordion-header>
    <p-accordion-content>
      {{ text$ | async }}
    </p-accordion-content>
  </p-accordion-panel>
</p-accordion>

Using this syntax, all content is immediately instantiated into the DOM, subscribing and evaluating the observables, firing all required backend calls at page load time. This is not what I want. I took a look at the PrimeNG accordion source code, and it seems the new framework doesn't use templates anymore, so the key mechanism that made the old approach work is not available unless you still use the deprecated AccordionTab component.

I found a way to get the old lazy behaviour again, using @defer:

<h2>Using p-accordion-panel with &commat;defer blocks</h2>
<p-accordion>
  <p-accordion-panel *ngFor="let text$ of new_defer$s; index as idx" value="{{idx}}">
    <p-accordion-header>Tab {{idx+1}}</p-accordion-header>
    <p-accordion-content>
      @defer (on viewport; prefetch on immediate)
      {
        {{ text$ | async }} 
      } @placeholder {
        <span>fetching data...</span>
      }
    </p-accordion-content>
  </p-accordion-panel>
</p-accordion>

This feels like the wrong tool for the problem, though. @defer is primarily meant to delay loading of components, and being able to delay the instantiation of the @defer block is just a side effect. In my case, I don't care about delayed loading (as I incidacted with prefetch on immedate), so I am afraid of extra overhead introduced by the delay-load mechanism associated with @defer.

My questions thus are

  1. Did I miss an obvious way the new Accordion component can be made "lazy", i.e. instantiating the content into the DOM on first open instead of on page load?
  2. Is @defer(prefetch on immediate) actually meant to solve my problem, using @defer is how you are supposed to delay instantiation of initially hidden page parts ("below the fold")?
  3. If no, is there a different language mechanism in Angular that allows me to easily make content a template that will be auto-instantiated as soon as it gets visible?

See a fully working project on StackBlitz:

https://stackblitz-starters-q1eyv1no.stackblitz.io/


Solution

  • The @defer approach seems perfectly valid to me, maybe you do not prefer using @defer without SSR.

    You just need to create/destroy the active tab to achieve this behaviour.

    For this we use the [(value)]="selected" to store the current tab index which was opened. Then the API will trigger on open.

    But @defer seems a better choice because it will load the component only when it actually needed (opened by the user) and it does not destroy the tab content during future opens, seems to be more performant.

    <h2>Using p-accordion-panel</h2>
    <p-accordion [(value)]="selected">
      <p-accordion-panel
        *ngFor="let text$ of new_direct$s; index as idx"
        value="{{ idx }}"
      >
        <p-accordion-header>Tab {{ idx + 1 }}</p-accordion-header>
        @if(selected !== '' && +selected === idx) {
          <p-accordion-content>
            {{ text$ | async }}
          </p-accordion-content>
        }
      </p-accordion-panel>
    </p-accordion>
    

    Stackblitz Demo