У меня есть угловое приложение (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.
Я не уверен, в чем основная причина проблемы, но виновник использует 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);
}
}
@PaoloDeLaurentiis На самом деле take(2)
тоже работает, возможно, это лучше для вашего варианта использования, в любом случае я не уверен, что не так, возможно, команда angular на github сможет вам помочь!
Я пытался проголосовать за, но, похоже, мне это не разрешено. Извини.
Это странное поведение. Я везде добавлял take(1) или firstValueFrom, обдумывая все возможные решения для завершения наблюдаемого и завершения соединения. В любом случае, существует множество сценариев, в которых мне не нужен обратный поток с обновлениями в реальном времени, а нужен только первый результат. Это безумие, я не могу заставить его работать. Кстати, в другом проекте я решил проблему точно так же, удалив take(1). В другом случае я использовал getDoc, но импортировал его из firebase/firestore, а импорт из @angular/fire/firestore решил.