k3nsei/ng-in-viewport

inViewportAction called during Angular Universal server-side rendering

SeinopSys opened this issue · 8 comments

While trying to use this package as part of an Angular Universal app I noticed that the method I specified for the inViewportAction output was being called on the server side as well as on the client browser. I had to work around this by checking the platform with isPlatformBrowser inside the method to avoid unnecessary code execution.

It's not necessarily a huge issue but it'd be nice it I didn't have to use a workaround to avoid this behavior.

@SeinopSys it's desired behaviour. Library detects server environment and fires event once with visible: true. It's that way because hiding stuff from indexing bots like google bot isn't good practise.

But I agree that in next version we can introduce option to control that behaviour, with default as it's now.

In that case is there a chance to make this behavior configurable? I personally don't want it to fire on the server and it was quite confusing to me at first that it did.

I'm using this to lazy load images and it firing on the server seemed like it made the request wait until the server has downloaded all of the full resolution images before sending the page down to the client.

@SeinopSys you can use code below. It works pretty well and it's comes from new demo code that I'm preparing for new release.

import { isPlatformBrowser } from '@angular/common';
import {
  Directive,
  ElementRef,
  HostBinding,
  Inject,
  OnDestroy,
  OnInit,
  PLATFORM_ID,
  Renderer2,
  Self,
} from '@angular/core';
import { InViewportDirective } from 'ng-in-viewport';
import { fromEvent, Subject } from 'rxjs';
import { filter, take, takeUntil } from 'rxjs/operators';

@Directive({
  selector: '[invpLazyImage]',
})
export class LazyImageDirective implements OnInit, OnDestroy {
  @HostBinding('class.is-loading')
  public isLoading: boolean = false;

  @HostBinding('class.is-loaded')
  public isLoaded: boolean = false;

  private readonly destroy$: Subject<void> = new Subject();

  constructor(
    @Self() private inViewport: InViewportDirective,
    @Inject(PLATFORM_ID) private platformId: Object,
    private elementRef: ElementRef,
    private renderer: Renderer2
  ) {}

  public ngOnInit(): void {
    if (this.inViewport) {
      this.inViewport.inViewportAction
        .pipe(
          filter(({ visible }) => visible),
          take(1),
          takeUntil(this.destroy$)
        )
        .subscribe({ next: () => this.load() });
    }
  }

  public ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private load(): void {
    const element: unknown = this.elementRef.nativeElement;

    switch (true) {
      case this.isImageWithSrc(element):
        return this.whenImage(element as HTMLImageElement);
      case this.isPictureWithChildren(element):
        return this.whenPicture(element as HTMLPictureElement);
      default:
        return;
    }
  }

  private whenPicture(pictureElement: HTMLPictureElement): void {
    const children = Array.from<unknown>(pictureElement.children) as ReadonlyArray<
      HTMLImageElement | HTMLSourceElement
    >;

    children.forEach((element: HTMLImageElement | HTMLSourceElement): void => {
      switch (true) {
        case this.isSourceWithSrcSet(element):
          return this.whenSource(element as HTMLSourceElement);
        case this.isImageWithSrc(element):
          return this.whenImage(element as HTMLImageElement);
        default:
          return;
      }
    });
  }

  private whenSource(element: HTMLSourceElement): void {
    this.renderer.setAttribute(element, 'srcset', element.dataset['srcset']);
    this.listen(element);
  }

  private whenImage(element: HTMLImageElement): void {
    this.renderer.setAttribute(element, 'src', element.dataset['src']);
    this.listen(element);
  }

  private isPictureWithChildren(element: unknown): boolean {
    return element instanceof HTMLPictureElement && !!element.children.length;
  }

  private isSourceWithSrcSet(element: unknown): boolean {
    return element instanceof HTMLSourceElement && !!element.dataset['srcset'];
  }

  private isImageWithSrc(element: unknown): boolean {
    return element instanceof HTMLImageElement && !!element.dataset['src'];
  }

  private listen(element: HTMLElement): void {
    const complete: () => void = () => {
      this.isLoading = false;
      this.isLoaded = true;
    };

    const createListener: () => void = () => {
      this.isLoading = true;

      fromEvent(element, 'load')
        .pipe(
          take(1),
          takeUntil(this.destroy$)
        )
        .subscribe({ complete });
    };

    isPlatformBrowser(this.platformId) ? createListener() : complete();
  }
}
<mat-grid-list cols="2" rowHeight="2:1">
  <ng-container *ngFor="let picture of pictures; index as i">
    <mat-grid-tile class="tile">
      <picture class="tile__picture" invpLazyImage inViewport>
        <source [attr.data-srcset]="picture?.large | sanitize: 'url'" media="(min-width: 1200px)" />
        <source [attr.data-srcset]="picture?.medium | sanitize: 'url'" media="(min-width: 700px)" />
        <source [attr.data-srcset]="picture?.small | sanitize: 'url'" media="(min-width: 300px)" />
        <img [attr.data-src]="picture?.tiny | sanitize: 'url'" [alt]="'Random Image #' + i" />
      </picture>
    </mat-grid-tile>
  </ng-container>
</mat-grid-list>

@SeinopSys you are using HttpClient to fetch image as blob. Better is to leave that to browser by setting src on image element. By doing that you are reducing your code base and you will don't have problem that your reported anymore.

I use a smaller base64 encoded image as the original src value when the image is inserted into the DOM as a lower-resolution placeholder. If I were to immediately set the src attribute to an image that isn't yet cached by the browser it would cause layout jumping and flashing which I especially wanted to avoid with my approach.

You can always have two images and showing or hiding they using css classes.

The only problem I can see with that is I'm not certain the browser will load the image if it detects it's not visible yet, but if that's not the case then that's a good idea as well. However, I still believe it would be a good idea to make this behavior a boolean setting somewhere, most probably when adding it to the imports array in app.module.ts