Firebase Firestore блокирует стабильность приложения Angular, тем самым блокируя гидратацию в ssr

У меня есть угловое приложение (17.3.x) с ssr и гидратацией. Используя Firebase Firestore, при определенных условиях, которые я не могу определить, приложение не становится стабильным на клиенте (оно становится на сервере), если не через 120 секунд, что означает, что гидратация срабатывает не сразу, а только примерно через 120 секунд.

Я написал проблему в репозитории angular fire, но ответа пока нет. Я сообщаю об этом здесь вместе с демонстрационным источником, который показывает проблему.

Информация о версии

Угловой:

17.0.9

Огневая база:

10.7.1

Угловой Файр:

17.0.1

Другое (например, Ionic/Cordova, Node, браузер, операционная система):

Окна

Как воспроизвести эти условия

Репозиторий GitHub для клонирования, поскольку для воспроизведения требуется SSR

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

Шаги по настройке и воспроизведению

Клонировать репозиторий

установить зависимости

запуск npm

Ожидаемое поведение

Приложение Angular станет стабильным в разумные сроки (миллисы)

Фактическое поведение

Проблема в том, что Firebase не позволяет угловому приложению стать стабильным в клиенте, поэтому процесс гидратации не запускается, кроме как после ожидания около 120 секунд.

Редактировать:

К сожалению, у меня все еще есть эта проблема в приведенной выше демонстрации и в другом проекте (с использованием angular 17.3.x и Firebase 10.11.x), и на этот раз с использованием метода getDoc.

Я почти уверен, что проблема возникла в SDK Firestore, поскольку две ожидающие макрозадачи, которые блокируют стабильность Angular, указывают на setTimout внутри исходного кода Firestore.

Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Angular и React для вашего проекта веб-разработки?
Angular и React для вашего проекта веб-разработки?
Когда дело доходит до веб-разработки, выбор правильного front-end фреймворка имеет решающее значение. Angular и React - два самых популярных...
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Эпизод 23/17: Twitter Space о будущем Angular, Tiny Conf
Мы провели Twitter Space, обсудив несколько проблем, связанных с последними дополнениями в Angular. Также прошла Angular Tiny Conf с 25 докладами.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
Мое недавнее углубление в Angular
Мое недавнее углубление в Angular
Недавно я провел некоторое время, изучая фреймворк Angular, и я хотел поделиться своим опытом со всеми вами. Как человек, который любит глубоко...
Освоение Observables и Subjects в Rxjs:
Освоение Observables и Subjects в Rxjs:
Давайте начнем с основ и постепенно перейдем к более продвинутым концепциям в RxJS в Angular
1
0
68
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Я не уверен, в чем основная причина проблемы, но виновник использует firstValueFrom и take(1) на collectionData(query(ref, ...qc)), если мы просто позволим возвращаемому наблюдаемому из collectionData как есть, в коде не будет проблем, связанных с whenStable . Не понимаю почему, может кто-то еще ответит.

Но, например, нам просто нужно удалить take(1), и это приведет к нормальному поведению!

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

Например, 4, нам просто нужно удалить firstValueFrom, и это приведет к нормальному поведению! нам это не нужно, поскольку collectionData просто возвращает наблюдаемое, а не обещание!

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

ПОЛНЫЙ КОД:

ПРИЛОЖЕНИЕ

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.info('APP is STABLE: ', v)),
    tap(() => setTimeout(() => this.cd.detectChanges(), 0))
  );
}

ПРИМЕР 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);
  }
}

ПРИМЕР 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);
  }
}

ПРИМЕР 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);
  }
}

ПРИМЕР 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

Это странное поведение. Я везде добавлял take(1) или firstValueFrom, обдумывая все возможные решения для завершения наблюдаемого и завершения соединения. В любом случае, существует множество сценариев, в которых мне не нужен обратный поток с обновлениями в реальном времени, а нужен только первый результат. Это безумие, я не могу заставить его работать. Кстати, в другом проекте я решил проблему точно так же, удалив take(1). В другом случае я использовал getDoc, но импортировал его из firebase/firestore, а импорт из @angular/fire/firestore решил.

Paolo De Laurentiis 02.05.2024 09:40

@PaoloDeLaurentiis На самом деле take(2) тоже работает, возможно, это лучше для вашего варианта использования, в любом случае я не уверен, что не так, возможно, команда angular на github сможет вам помочь!

Naren Murali 02.05.2024 09:49

Я пытался проголосовать за, но, похоже, мне это не разрешено. Извини.

Paolo De Laurentiis 02.05.2024 17:05

Другие вопросы по теме