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.
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);
}
}