angulargoogle-cloud-firestoreserver-side-renderingangularfirehydration

Firebase firestore blocks angular app from becoming stable thus blocks hydration in ssr


I have an angular app (17.3.x) with ssr and hydration. Using firebase firestore, under certain conditions I'm not able to identify, the app doesn't become stable in the client (it does in the server) if not after 120sec, which means hydration doesn't kicks in immediately but only after around 120 secs.

I wrote an issue on angular fire repository, but no answer so far. I report it here together with a demo source that shows the problem.

Version info

Angular:

17.0.9

Firebase:

10.7.1

AngularFire:

17.0.1

Other (e.g. Ionic/Cordova, Node, browser, operating system):

Windows

How to reproduce these conditions

GitHub repo to clone as needs SSR to reproduce

https://github.com/pdela/testing-ng17-ssr

Steps to set up and reproduce

Clone the repo

install dependencies

npm run start

Expected behavior

Angular app to become stable in reasonable time (millis)

Actual behavior

The problem is that firebase prevents angular app to become stable in client, thus hydration process doesn't start, except after waiting for around 120 seconds

Edit:

Unfortunately I still have this problem in the demo above and in another project ( using angular 17.3.x and firebase 10.11.x) and this time using getDoc method.

I'm pretty sure the problem is originated in firestore SDK, as the 2 pending macrotasks that blocks angular being stable point to a setTimout inside firestore source code.


Solution

  • I am not sure what is the root cause for the issue, but the culprit is using firstValueFrom and take(1) on the collectionData(query(ref, ...qc)), if we just let the observable from collectionData be returned as it is, there is no issues on the code, related to whenStable. I don't have understanding why, maybe someone else can answer.

    But for example 1, we just need to remove the take(1) and this causes normal behaviour to happen!

    ...
    return collectionData(query(ref, ...qc)).pipe(
      // take(1) // <- changed here!
      );
    ...
    

    For example 4, we just need to remove the firstValueFrom and this causes normal behaviour to happen! we don't need it since, the collectionData just returns an observable and not a promise!

    ...
    return collectionData(query(ref, ...qc)); 
    ...
    

    FULL CODE:

    APP

    import { CommonModule } from '@angular/common';
    import {
      ApplicationRef,
      ChangeDetectorRef,
      Component,
      inject,
    } from '@angular/core';
    import { RouterLink, RouterOutlet } from '@angular/router';
    import { Observable, tap } from 'rxjs';
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [CommonModule, RouterOutlet, RouterLink],
      styles: [
        `
          .btn {
            display: block;
            margin-top: 40px;
          }
        `,
      ],
      template: `
        <p>
          FOLLOW ONE OF THESE LINKS THEN RELOAD BROWSER PAGE AS IF NAVIGATIG
          DIRECTLY TO THE LINK
        </p>
        <p>
          App doesn't become stable and hydration doesn't happen, except after
          around 120secs
        </p>
        <div>
          <p style="color: blue; font-weight: bold;">
            APP is STABLE: {{ isStable$ | async }}
          </p>
        </div>
    
        <a class="btn" routerLink="test-1"
          >1) AF - COLLECTION DATA AFTER NEXT RENDER- HANGS CLIENT</a
        >
    
        <a class="btn" routerLink="test-2"
          >2) AF - COLLECTION DATA - HANGS CLIENT</a
        >
    
        <a class="btn" routerLink="test-3">3) AF - GET DOCS - WORKS</a>
    
        <a class="btn" routerLink="test-4"
          >4) AF - FIRST VALUE FROM COLLECTION DATA - HANGS CLIENT</a
        >
        <br />
        <hr />
        <br />
        <router-outlet></router-outlet>
      `,
    })
    export class AppComponent {
      appRef = inject(ApplicationRef);
      cd = inject(ChangeDetectorRef);
    
      isStable$: Observable<boolean> = this.appRef.isStable.pipe(
        tap((v) => console.log('APP is STABLE: ', v)),
        tap(() => setTimeout(() => this.cd.detectChanges(), 0))
      );
    }
    

    EXAMPLE 1

    import { CommonModule } from '@angular/common';
    import {
      Component,
      DestroyRef,
      afterNextRender,
      inject,
    } from '@angular/core';
    import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
    import {
      CollectionReference,
      Firestore,
      QueryConstraint,
      collection,
      collectionData,
      limit,
      orderBy,
      query,
      startAfter,
      where,
    } from '@angular/fire/firestore';
    import { RouterOutlet } from '@angular/router';
    import {
      BehaviorSubject,
      Observable,
      Subject,
      concatMap,
      distinctUntilChanged,
      scan,
      startWith,
      take,
      takeWhile,
      tap,
      throttleTime,
    } from 'rxjs';
    
    @Component({
      selector: 'app-test-1',
      standalone: true,
      imports: [CommonModule, RouterOutlet],
      template: `
        <div>TEST 1</div>
    
        <ng-container *ngIf="todos$ | async as todos">
          Loaded todos: {{ todos.length }}
          <button (click)="loadMore(todos)">LOAD MORE</button>
          <div *ngIf="noMoreAvailable">NO MORE AVAILABLE</div>
        </ng-container>
      `,
    })
    export default class Test1Component {
      destroyRef = inject(DestroyRef);
      scbWebFbFs = inject(Firestore);
    
      noMoreAvailable = false;
      reachedLastInViewSbj = new Subject<any>();
      todos$: Observable<any[]> | undefined;
    
      constructor() {
        afterNextRender(() => {
          this.todos$ = this.reachedLastInViewSbj.pipe(
            takeWhile(() => !this.noMoreAvailable),
            throttleTime(500),
            distinctUntilChanged((a, b) => a?.id === b?.id),
            startWith(null),
            concatMap((offsetItem) => this.getTodosColletionData(offsetItem, 10)),
            tap((acts) => (this.noMoreAvailable = acts.length === 0)),
            scan((acc, batch) => [...acc, ...batch], [] as any[]),
            takeUntilDestroyed(this.destroyRef)
          );
        });
      }
    
      getTodosColletionData(
        startAfterTodo: any | null | undefined,
        batchSize: number
      ) {
        const ref = collection(
          this.scbWebFbFs,
          `todos`
        ) as CollectionReference<any>;
        const qc: QueryConstraint[] = [orderBy('id', 'desc'), limit(batchSize)];
        if (startAfterTodo) {
          qc.push(startAfter(startAfterTodo.id));
        }
    
        return collectionData(query(ref, ...qc)).pipe(
          // take(1)
          );
      }
    
      loadMore(todos: any[] | null) {
        const lastInView = todos?.slice(-1)[0];
        if (!lastInView) return;
        this.reachedLastInViewSbj.next(lastInView);
      }
    }
    

    EXAMPLE 2:

    import { CommonModule } from '@angular/common';
    import {
      ApplicationRef,
      ChangeDetectorRef,
      Component,
      DestroyRef,
      afterNextRender,
      inject,
    } from '@angular/core';
    import {
      CollectionReference,
      Firestore,
      QueryConstraint,
      collection,
      collectionData,
      limit,
      orderBy,
      query,
      startAfter,
      where,
    } from '@angular/fire/firestore';
    import { RouterOutlet } from '@angular/router';
    import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
    import {
      BehaviorSubject,
      Observable,
      Subject,
      concatMap,
      of,
      scan,
      startWith,
      take,
      takeWhile,
      tap,
    } from 'rxjs';
    
    @Component({
      selector: 'app-test-2',
      standalone: true,
      imports: [CommonModule, RouterOutlet],
      template: `
        <div>TEST 2</div>
        <ng-container *ngIf="todos$ | async as todos">
          Loaded todos: {{ todos.length }}
          <button (click)="loadMore(todos)">LOAD MORE</button>
          <div *ngIf="noMoreAvailable">NO MORE AVAILABLE</div>
        </ng-container>
      `,
    })
    export default class Test2Component {
      destroyRef = inject(DestroyRef);
      scbWebFbFs = inject(Firestore);
    
      noMoreAvailable = false;
      reachedLastInViewSbj = new Subject<any>();
      todos$: Observable<any[]> = this.reachedLastInViewSbj.pipe(
        startWith(null),
        concatMap((offsetTodo) =>
          this.getTodosColletionData(offsetTodo, 10).pipe(
            tap((todos) => (this.noMoreAvailable = todos.length === 0))
          )
        ),
        scan((acc, batch) => [...acc, ...batch], [] as any[]),
        takeWhile(() => !this.noMoreAvailable),
        takeUntilDestroyed(this.destroyRef)
      );
    
      getTodosColletionData(
        startAfterTodo: any | null | undefined,
        batchSize: number
      ) {
        const ref = collection(
          this.scbWebFbFs,
          `todos`
        ) as CollectionReference<any>;
        const qc: QueryConstraint[] = [orderBy('id', 'desc'), limit(batchSize)];
        if (startAfterTodo) {
          qc.push(startAfter(startAfterTodo.id));
        }
    
        return collectionData(query(ref, ...qc));
      }
    
      loadMore(todos: any[] | null) {
        const lastInView = todos?.slice(-1)[0];
        if (!lastInView) return;
        this.reachedLastInViewSbj.next(lastInView);
      }
    }
    

    EXAMPLE 3:

    import { CommonModule } from '@angular/common';
    import {
      ApplicationRef,
      ChangeDetectorRef,
      Component,
      DestroyRef,
      afterNextRender,
      inject,
    } from '@angular/core';
    import {
      CollectionReference,
      Firestore,
      QueryConstraint,
      collection,
      collectionData,
      getDocs,
      limit,
      orderBy,
      query,
      startAfter,
      where,
    } from '@angular/fire/firestore';
    import { RouterOutlet } from '@angular/router';
    import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
    import {
      BehaviorSubject,
      Observable,
      Subject,
      concatMap,
      from,
      map,
      of,
      scan,
      startWith,
      take,
      takeWhile,
      tap,
    } from 'rxjs';
    
    @Component({
      selector: 'app-test-3',
      standalone: true,
      imports: [CommonModule, RouterOutlet],
      template: `
        <div>TEST 3</div>
        <ng-container *ngIf="todos$ | async as todos">
          Loaded todos: {{ todos.length }}
          <button (click)="loadMore(todos)">LOAD MORE</button>
          <div *ngIf="noMoreAvailable">NO MORE AVAILABLE</div>
        </ng-container>
      `,
    })
    export default class Test3Component {
      destroyRef = inject(DestroyRef);
      scbWebFbFs = inject(Firestore);
    
      noMoreAvailable = false;
      reachedLastInViewSbj = new Subject<any>();
      todos$: Observable<any[]> = this.reachedLastInViewSbj.pipe(
        startWith(null),
        concatMap((offsetTodo) =>
          this.getTodosGetDocs(offsetTodo, 10).pipe(
            tap((todos) => (this.noMoreAvailable = todos.length === 0))
          )
        ),
        scan((acc, batch) => [...acc, ...batch], [] as any[]),
        takeWhile(() => !this.noMoreAvailable),
        takeUntilDestroyed(this.destroyRef)
      );
    
      getTodosGetDocs(startAfterTodo: any | null | undefined, batchSize: number) {
        const ref = collection(this.scbWebFbFs, `todos`);
        const qc: QueryConstraint[] = [orderBy('id', 'desc'), limit(batchSize)];
        if (startAfterTodo) {
          qc.push(startAfter(startAfterTodo.id));
        }
    
        return from(getDocs(query(ref, ...qc))).pipe(
          map((v) => {
            return v.docs.map((s) => s.data());
          }),
          take(1)
        );
      }
    
      loadMore(todos: any[] | null) {
        const lastInView = todos?.slice(-1)[0];
        if (!lastInView) return;
        this.reachedLastInViewSbj.next(lastInView);
      }
    }
    

    EXAMPLE 4:

    import { CommonModule } from '@angular/common';
    import { Component, DestroyRef, inject } from '@angular/core';
    import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
    import {
      CollectionReference,
      Firestore,
      QueryConstraint,
      collection,
      collectionData,
      limit,
      orderBy,
      query,
      startAfter,
    } from '@angular/fire/firestore';
    import { RouterOutlet } from '@angular/router';
    import {
      Observable,
      Subject,
      concatMap,
      firstValueFrom,
      from,
      scan,
      startWith,
      take,
      takeWhile,
      tap,
    } from 'rxjs';
    
    @Component({
      selector: 'app-test-4',
      standalone: true,
      imports: [CommonModule, RouterOutlet],
      template: `
        <div>TEST 4</div>
        <ng-container *ngIf="todos$ | async as todos">
          Loaded todos: {{ todos.length }}
          <button (click)="loadMore(todos)">LOAD MORE</button>
          <div *ngIf="noMoreAvailable">NO MORE AVAILABLE</div>
        </ng-container>
      `,
    })
    export default class Test4Component {
      destroyRef = inject(DestroyRef);
      scbWebFbFs = inject(Firestore);
    
      noMoreAvailable = false;
      reachedLastInViewSbj = new Subject<any>();
      todos$: Observable<any[]> = this.reachedLastInViewSbj.pipe(
        startWith(null),
        concatMap((offsetTodo) =>
          from(this.getTodosColletionData(offsetTodo, 10)).pipe(
            tap((todos) => (this.noMoreAvailable = todos.length === 0))
          )
        ),
        scan((acc, batch) => [...acc, ...batch], [] as any[]),
        takeWhile(() => !this.noMoreAvailable),
        takeUntilDestroyed(this.destroyRef)
      );
    
      getTodosColletionData(
        startAfterTodo: any | null | undefined,
        batchSize: number
      ) {
        const ref = collection(
          this.scbWebFbFs,
          `todos`
        ) as CollectionReference<any>;
        const qc: QueryConstraint[] = [orderBy('id', 'desc'), limit(batchSize)];
        if (startAfterTodo) {
          qc.push(startAfter(startAfterTodo.id));
        }
    
        return collectionData(query(ref, ...qc));
      }
    
      loadMore(todos: any[] | null) {
        const lastInView = todos?.slice(-1)[0];
        if (!lastInView) return;
        this.reachedLastInViewSbj.next(lastInView);
      }
    }
    

    Github Repo