11

I have gone ahead and implemented angular universal and able to render static part of html via server side rendering. Issue that I face is, API calls are being made and server is rendering the html without waiting for the http call to complete. So, part where my template is dependent on data obtained from api call is not being rendered on server.

Further Info:

I use authentication in node server which serve the index html only if the user is authenticated and sets the cookie on response.

Whenever I make an API call from angular, I also send the cookie as header as the dependent services also validates user with the token. For server side rendering, as the cookie will not be available at server level, I have successfully injected request and pick the cookie for the API call. So, API calls are successful but server is not waiting to render till the promise resolves.

Steps that I have tried with no success:

I have changed my zone version as suggested in this comment https://github.com/angular/universal-starter/issues/181#issuecomment-250177280

Please let me know if any further info is required.

Directing me to a angular universal boilerplate which has http calls involved would help me.

Chan15
  • 929
  • 2
  • 9
  • 16

5 Answers5

5

I 've created a service for doing the async API calls using muradm code.

Gist link.

import { Injectable } from '@angular/core';
import { Observable, Observer, Subscription } from 'rxjs';



@Injectable({
  providedIn: 'root'
})
export class AsyncApiCallHelperService {

  taskProcessor: MyAsyncTaskProcessor;
  constructor() {
    this.taskProcessor = new MyAsyncTaskProcessor();
  }

  doTask<T>(promise: Promise<T>) {
    return <Observable<T>> this.taskProcessor.doTask(promise);
  }
}

declare const Zone: any;

export abstract class ZoneMacroTaskWrapper<S, R> {
  wrap(request: S): Observable<R> {
    return new Observable((observer: Observer<R>) => {
      let task;
      let scheduled = false;
      let sub: Subscription|null = null;
      let savedResult: any = null;
      let savedError: any = null;

      // tslint:disable-next-line:no-shadowed-variable
      const scheduleTask = (_task: any) => {
        task = _task;
        scheduled = true;

        const delegate = this.delegate(request);
        sub = delegate.subscribe(
            res => savedResult = res,
            err => {
              if (!scheduled) {
                throw new Error(
                    'An http observable was completed twice. This shouldn\'t happen, please file a bug.');
              }
              savedError = err;
              scheduled = false;
              task.invoke();
            },
            () => {
              if (!scheduled) {
                throw new Error(
                    'An http observable was completed twice. This shouldn\'t happen, please file a bug.');
              }
              scheduled = false;
              task.invoke();
            });
      };

      // tslint:disable-next-line:no-shadowed-variable
      const cancelTask = (_task: any) => {
        if (!scheduled) {
          return;
        }
        scheduled = false;
        if (sub) {
          sub.unsubscribe();
          sub = null;
        }
      };

      const onComplete = () => {
        if (savedError !== null) {
          observer.error(savedError);
        } else {
          observer.next(savedResult);
          observer.complete();
        }
      };

      // MockBackend for Http is synchronous, which means that if scheduleTask is by
      // scheduleMacroTask, the request will hit MockBackend and the response will be
      // sent, causing task.invoke() to be called.
      const _task = Zone.current.scheduleMacroTask(
          'ZoneMacroTaskWrapper.subscribe', onComplete, {}, () => null, cancelTask);
      scheduleTask(_task);

      return () => {
        if (scheduled && task) {
          task.zone.cancelTask(task);
          scheduled = false;
        }
        if (sub) {
          sub.unsubscribe();
          sub = null;
        }
      };
    });
  }

  protected abstract delegate(request: S): Observable<R>;
}

export class MyAsyncTaskProcessor extends
    ZoneMacroTaskWrapper<Promise<any>, any> {

  constructor() { super(); }

  // your public task invocation method signature
  doTask(request: Promise<any>): Observable<any> {
    // call via ZoneMacroTaskWrapper
    return this.wrap(request);
  }

  // delegated raw implementation that will be called by ZoneMacroTaskWrapper
  protected delegate(request: Promise<any>): Observable<any> {
    return new Observable<any>((observer: Observer<any>) => {
      // calling observer.next / complete / error
      request
      .then(result => {
        observer.next(result);
        observer.complete();
      }).catch(error => observer.error(error));
    });
  }
}

I hope this helps someone.

Alvaro
  • 95
  • 1
  • 8
  • This works for me too thanks, to get Angular to wait for non-Angular asynchronous calls to finish before rendering (I'm making calls to AWS DynamoDB using their SDK). One of the comments in the Gist link shows how to inject and use this helper class. – rangfu Dec 17 '19 at 12:18
4

I just used Zone directly:

Declare the Zone variable in your component:

declare const Zone: any;

Create a macro task.

const t = Zone.current.scheduleMacroTask (
  i.reference, () => {}, {}, () => {}, () => {}
);

Do your http async call. In the response callback / promise let the macro task know its done:

t.invoke();

The above is the simplest form of the solution. You would obviously need to handle errors and timeouts.

Khurram Ali
  • 835
  • 12
  • 18
  • 11
    What is `i.reference`? – Shy Agam Oct 12 '19 at 17:24
  • Works like a charm. Worth noting that there is no need to create a task for each async call. It is enough to create one that is invoked once all async calls finish. – Shy Agam Oct 12 '19 at 20:18
  • @ShyAgam it is a string used as identifier. See https://blog.bitsrc.io/how-angular-uses-ngzone-zone-js-for-dirty-checking-faa12f98cd49 – pubkey Mar 24 '21 at 23:19
  • For an exact solution, see [here](https://stackoverflow.com/a/58474585/271450) – Jonathan Oct 07 '21 at 00:16
3

Finally, solution was to schedule external API async calls as macro tasks. The explanation in this issue helped. Implementing ZoneMacroTaskWrapper like helper wrapper class for external API async calls did the rendering process wait on external promises.

Currently, ZoneMacroTaskWrapper is not exposed to public API. But there is a promise in issue to provide documentation.

For illustration purposes monkey typing example:

export class MyAsyncTaskProcessor extends
    ZoneMacroTaskWrapper<MyRequest, MyResult> {

  constructor() { super(); }

  // your public task invocation method signature
  doTask(request: MyRequest): Observable<MyResult> {
    // call via ZoneMacroTaskWrapper
    return this.wrap(request);
  }

  // delegated raw implementation that will be called by ZoneMacroTaskWrapper
  protected delegate(request: MyRequest): Observable<MyResult> {
    return new Observable<MyResult>((observer: Observer<MyResult>) => {
      // calling observer.next / complete / error
      new Promise((resolve, error) => {
        // do something async
      }).then(result => {
        observer.next(result);
        observer.complete();
      }).catch(error => observer.error(error));
    });
  }
}
muradm
  • 1,973
  • 19
  • 30
  • @muradam can you please explain it with some code or link function? – Abdul Hameed Sep 26 '18 at 10:59
  • I'm away from my dev env. Check `ZoneClientBackend` implementation. Basically it extends `ZoneMacroTaskWrapper`, which has protected abstract method `delegate`. In delegate you do your async code. When `handle` is called by user, `ZoneMacroTaskWrapper` will do necessary stuff and call your `delegate`. `ZoneClientBackend` in the same file with `ZoneMacroTaskWrapper`. – muradm Sep 27 '18 at 18:25
  • Wrapper itself is parametrized with `S` (which is input) and `R` (which is output). So you can do pretty much anything with it, not only http. – muradm Sep 27 '18 at 18:31
  • @AbdulHameed explained above – muradm Sep 27 '18 at 19:00
  • @AbdulHameed, rough example added to illustrate usage – muradm Oct 03 '18 at 12:28
  • Great! I was looking for same, will surely check after my current task today (y) – Abdul Hameed Oct 03 '18 at 12:44
3

I had a few issues/concerns with the previous solutions. Here's my solution

  • Works with Promises and Observables
  • Has options for Observables to determine when the task will be finished (eg Completion/Error, First Emit, Other)
  • Has option to warn when a task is taking too long to complete
  • Angular UDK doesn't seem to respect tasks that were initiated outside of the component (eg, by NGXS). This provides an awaitMacroTasks() that may be called from the component to fix it.

Gist

/// <reference types="zone.js" />
import { Inject, Injectable, InjectionToken, OnDestroy, Optional } from "@angular/core";
import { BehaviorSubject, Observable, of, Subject, Subscription } from "rxjs";
import { finalize, switchMap, takeUntil, takeWhile, tap } from "rxjs/operators";

export const MACRO_TASK_WRAPPER_OPTIONS = new InjectionToken<MacroTaskWrapperOptions>("MacroTaskWrapperOptions");

export interface MacroTaskWrapperOptions {
  wrapMacroTaskTooLongWarningThreshold?: number;
}

/*
* These utilities help Angular Universal know when
* the page is done loading by wrapping
* Promises and Observables in ZoneJS Macro Tasks.
*
* See: https://gist.github.com/sparebytes/e2bc438e3cfca7f6687f1d61287f8d72
* See: https://github.com/angular/angular/issues/20520
* See: https://stackoverflow.com/a/54345373/787757
*
* Usage:
*
  ```ts
  @Injectable
  class MyService {
    constructor(private macroTaskWrapper: MacroTaskWrapperService) {}

    doSomething(): Observable<any> {
      return this.macroTaskWrapper.wrapMacroTask("MyService.doSomething", getMyData())
    }
  }

  @Component
  class MyComponent {
    constructor(private macroTaskWrapper: MacroTaskWrapperService) {}

    ngOnInit() {
      // You can use wrapMacroTask here
      this.macroTaskWrapper.wrapMacroTask("MyComponent.ngOnInit", getMyData())

      // If any tasks have started outside of the component use this:
      this.macroTaskWrapper.awaitMacroTasks("MyComponent.ngOnInit");
    }
  }
  ```
*
*/
@Injectable({ providedIn: "root" })
export class MacroTaskWrapperService implements OnDestroy {
  /** Override this value to change the warning time */
  wrapMacroTaskTooLongWarningThreshold: number;

  constructor(@Inject(MACRO_TASK_WRAPPER_OPTIONS) @Optional() options?: MacroTaskWrapperOptions) {
    this.wrapMacroTaskTooLongWarningThreshold =
      options && options.wrapMacroTaskTooLongWarningThreshold != null ? options.wrapMacroTaskTooLongWarningThreshold : 10000;
  }

  ngOnDestroy() {
    this.macroTaskCount.next(0);
    this.macroTaskCount.complete();
  }

  /**
   * Useful for waiting for tasks that started outside of a Component
   *
   * awaitMacroTasks$().subscribe()
   **/
  awaitMacroTasks$(label: string, stackTrace?: string): Observable<number> {
    return this._wrapMacroTaskObservable(
      "__awaitMacroTasks__" + label,
      of(null)
        // .pipe(delay(1))
        .pipe(switchMap(() => this.macroTaskCount))
        .pipe(takeWhile(v => v > 0)),
      null,
      "complete",
      false,
      stackTrace,
    );
  }

  /**
   * Useful for waiting for tasks that started outside of a Component
   *
   * awaitMacroTasks()
   **/
  awaitMacroTasks(label: string, stackTrace?: string): Subscription {
    // return _awaitMacroTasksLogged();
    return this.awaitMacroTasks$(label, stackTrace).subscribe();
  }

  awaitMacroTasksLogged(label: string, stackTrace?: string): Subscription {
    console.error("MACRO START");
    return this.awaitMacroTasks$(label, stackTrace).subscribe(() => {}, () => {}, () => console.error("MACRO DONE"));
  }

  /**
   * Starts a Macro Task for a promise or an observable
   */
  wrapMacroTask<T>(
    label: string,
    request: Promise<T>,
    warnIfTakingTooLongThreshold?: number | null,
    isDoneOn?: IWaitForObservableIsDoneOn<T> | null,
    stackTrace?: string | null,
  ): Promise<T>;
  wrapMacroTask<T>(
    label: string,
    request: Observable<T>,
    warnIfTakingTooLongThreshold?: number | null,
    isDoneOn?: IWaitForObservableIsDoneOn<T> | null,
    stackTrace?: string | null,
  ): Observable<T>;
  wrapMacroTask<T>(
    /** Label the task for debugging purposes */
    label: string,
    /** The observable or promise to watch */
    request: Promise<T> | Observable<T>,
    /** Warn us if the request takes too long. Set to 0 to disable */
    warnIfTakingTooLongThreshold?: number | null,
    /** When do we know the request is done */
    isDoneOn?: IWaitForObservableIsDoneOn<T> | null,
    /** Stack trace to log if the task takes too long */
    stackTrace?: string | null,
  ): Promise<T> | Observable<T> {
    if (request instanceof Promise) {
      return this.wrapMacroTaskPromise(label, request, warnIfTakingTooLongThreshold, stackTrace);
    } else if (request instanceof Observable) {
      return this.wrapMacroTaskObservable(label, request, warnIfTakingTooLongThreshold, isDoneOn, stackTrace);
    }

    // Backup type check
    if ("then" in request && typeof (request as any).then === "function") {
      return this.wrapMacroTaskPromise(label, request, warnIfTakingTooLongThreshold, stackTrace);
    } else {
      return this.wrapMacroTaskObservable(label, request as Observable<T>, warnIfTakingTooLongThreshold, isDoneOn, stackTrace);
    }
  }

  /**
   * Starts a Macro Task for a promise
   */
  async wrapMacroTaskPromise<T>(
    /** Label the task for debugging purposes */
    label: string,
    /** The Promise to watch */
    request: Promise<T>,
    /** Warn us if the request takes too long. Set to 0 to disable */
    warnIfTakingTooLongThreshold?: number | null,
    /** Stack trace to log if the task takes too long */
    stackTrace?: string | null,
  ): Promise<T> {
    // Initialize warnIfTakingTooLongThreshold
    if (typeof warnIfTakingTooLongThreshold !== "number") {
      warnIfTakingTooLongThreshold = this.wrapMacroTaskTooLongWarningThreshold;
    }

    // Start timer for warning
    let hasTakenTooLong = false;
    let takingTooLongTimeout: any = null;
    if (warnIfTakingTooLongThreshold! > 0 && takingTooLongTimeout == null) {
      takingTooLongTimeout = setTimeout(() => {
        hasTakenTooLong = true;
        clearTimeout(takingTooLongTimeout);
        takingTooLongTimeout = null;
        console.warn(
          `wrapMacroTaskPromise: Promise is taking too long to complete. Longer than ${warnIfTakingTooLongThreshold}ms.`,
        );
        console.warn("Task Label: ", label);
        if (stackTrace) {
          console.warn("Task Stack Trace: ", stackTrace);
        }
      }, warnIfTakingTooLongThreshold!);
    }

    // Start the task
    const task: MacroTask = Zone.current.scheduleMacroTask("wrapMacroTaskPromise", () => {}, {}, () => {}, () => {});
    this.macroTaskStarted();

    // Prepare function for ending the task
    const endTask = () => {
      task.invoke();
      this.macroTaskEnded();

      // Kill the warning timer
      if (takingTooLongTimeout != null) {
        clearTimeout(takingTooLongTimeout);
        takingTooLongTimeout = null;
      }

      if (hasTakenTooLong) {
        console.warn("Long Running Macro Task is Finally Complete: ", label);
      }
    };

    // Await the promise
    try {
      const result = await request;
      endTask();
      return result;
    } catch (ex) {
      endTask();
      throw ex;
    }
  }

  /**
   * Starts a Macro Task for an observable
   */
  wrapMacroTaskObservable<T>(
    /** Label the task for debugging purposes */
    label: string,
    /** The observable to watch */
    request: Observable<T>,
    /** Warn us if the request takes too long. Set to 0 to disable */
    warnIfTakingTooLongThreshold?: number | null,
    /** When do we know the request is done */
    isDoneOn?: IWaitForObservableIsDoneOn<T> | null,
    /** Stack trace to log if the task takes too long */
    stackTrace?: string | null,
  ): Observable<T> {
    return this._wrapMacroTaskObservable(label, request, warnIfTakingTooLongThreshold, isDoneOn, true, stackTrace);
  }

  protected _wrapMacroTaskObservable<T>(
    label: string,
    request: Observable<T>,
    warnIfTakingTooLongThreshold?: number | null,
    isDoneOn?: IWaitForObservableIsDoneOn<T> | null,
    isCounted: boolean = true,
    stackTrace?: string | null,
  ): Observable<T> {
    return of(null).pipe(
      switchMap(() => {
        let counts = 0;

        // Determine emitPredicate
        let emitPredicate: (d: T) => boolean;
        if (isDoneOn == null || isDoneOn === "complete") {
          emitPredicate = alwaysFalse;
        } else if (isDoneOn === "first-emit") {
          emitPredicate = makeEmitCountPredicate(1);
        } else if ("emitCount" in isDoneOn) {
          emitPredicate = makeEmitCountPredicate(isDoneOn.emitCount);
        } else if ("emitPredicate" in isDoneOn) {
          emitPredicate = isDoneOn.emitPredicate;
        } else {
          console.warn("wrapMacroTaskObservable: Invalid isDoneOn value given. Defaulting to 'complete'.", isDoneOn);
          emitPredicate = alwaysFalse;
        }

        // Initialize warnIfTakingTooLongThreshold
        if (typeof warnIfTakingTooLongThreshold !== "number") {
          warnIfTakingTooLongThreshold = this.wrapMacroTaskTooLongWarningThreshold;
        }

        /** When task is null it means it hasn't been scheduled */
        let task: MacroTask | null = null;
        let takingTooLongTimeout: any = null;
        let hasTakenTooLong = false;

        /** Function to call when we have determined the request is complete */
        const endTask = () => {
          if (task != null) {
            task.invoke();
            task = null;
            if (hasTakenTooLong) {
              console.warn("Long Running Macro Task is Finally Complete: ", label);
            }
          }

          this.macroTaskEnded(counts);
          counts = 0;

          // Kill the warning timer
          if (takingTooLongTimeout != null) {
            clearTimeout(takingTooLongTimeout);
            takingTooLongTimeout = null;
          }
        };

        /** Used if the task is cancelled */
        const unsubSubject = new Subject();
        function unsub() {
          unsubSubject.next();
          unsubSubject.complete();
        }

        return of(null)
          .pipe(
            tap(() => {
              // Start the task if one hasn't started yet
              if (task == null) {
                task = Zone.current.scheduleMacroTask("wrapMacroTaskObservable", () => {}, {}, () => {}, unsub);
              }
              if (isCounted) {
                this.macroTaskStarted();
                counts++;
              }

              // Start timer for warning
              if (warnIfTakingTooLongThreshold! > 0 && takingTooLongTimeout == null) {
                takingTooLongTimeout = setTimeout(() => {
                  hasTakenTooLong = true;
                  clearTimeout(takingTooLongTimeout);
                  takingTooLongTimeout = null;
                  console.warn(
                    `wrapMacroTaskObservable: Observable is taking too long to complete. Longer than ${warnIfTakingTooLongThreshold}ms.`,
                  );
                  console.warn("Task Label: ", label);
                  if (stackTrace) {
                    console.warn("Task Stack Trace: ", stackTrace);
                  }
                }, warnIfTakingTooLongThreshold!);
              }
            }),
          )
          .pipe(switchMap(() => request.pipe(takeUntil(unsubSubject))))
          .pipe(
            tap(v => {
              if (task != null) {
                if (emitPredicate(v)) {
                  endTask();
                }
              }
            }),
          )
          .pipe(
            finalize(() => {
              endTask();
              unsubSubject.complete();
            }),
          );
      }),
    );
  }

  protected macroTaskCount = new BehaviorSubject(0);

  protected macroTaskStarted(counts: number = 1) {
    const nextTaskCount = this.macroTaskCount.value + counts;
    this.macroTaskCount.next(nextTaskCount);
    // console.log("Macro Task Count + ", counts, " = ", nextTaskCount);
  }
  protected macroTaskEnded(counts: number = 1) {
    const nextTaskCount = this.macroTaskCount.value - counts;
    this.macroTaskCount.next(nextTaskCount);
    // console.log("Macro Task Count - ", counts, " = ", nextTaskCount);
  }
}

export type IWaitForObservableIsDoneOn<T = any> =
  | "complete"
  | "first-emit"
  | { emitCount: number }
  | { emitPredicate: (d: T) => boolean };

// Utilities:

function makeEmitCountPredicate(emitCount: number) {
  let count = 0;
  return () => {
    count++;
    return count >= emitCount;
  };
}

function alwaysFalse() {
  return false;
}
sparebytes
  • 12,546
  • 3
  • 21
  • 32
  • 3
    I tried everything and this is the only solution that worked for me. Using angular 10 and SSR. Quite lengthy solution, I wonder if there is a more elegant way to control the rendering... can't believe something so necessary has to be so complicated. – Blasco Oct 21 '20 at 15:10
  • Thanks @sparebytes for sharing. I'm trying your solution that sounds great but I have hard time using it. Do you have a working project that uses it, I need more explanation on my side I have HttpErrorResponse :( Thanks! – Raphaël Roux Mar 03 '23 at 16:27
0

i've created a solution which fits my needs. Maybe it is helping both of us:

const obs = new Observable<Item<any>>(subscriber => {
  this.thirdPartyService.getItem(itemId).then((item) => {
    subscriber.next(item);
    subscriber.complete();
    return item;
  });
});
return obs.map(item => item.data); 
Sepanyol
  • 90
  • 7