Skip to content

Commit d5dbe99

Browse files
authored
fix(afs): fixing the metadata in snapshotChanges and more (#2670)
* metadata updates weren't getting to the `snapshotChanges` on collections due to `docChanges()` not including metadata in the changes, even when you ask for it * pull `spliceAndSlice` from rxfire and use it in combination with `distinctUntilChanged()` to reduce unneeded change detection cycles
1 parent 2dddefd commit d5dbe99

9 files changed

+166
-9
lines changed

sample/src/app/app.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { MessagingComponent } from './messaging/messaging.component';
3737
import { FunctionsComponent } from './functions/functions.component';
3838
import { FirestoreOfflineComponent } from './firestore-offline/firestore-offline.component';
3939
import { FirestoreOfflineModule } from './firestore-offline/firestore-offline.module';
40+
import { UpboatsComponent } from './upboats/upboats.component';
4041

4142
@NgModule({
4243
declarations: [
@@ -50,6 +51,7 @@ import { FirestoreOfflineModule } from './firestore-offline/firestore-offline.mo
5051
AuthComponent,
5152
MessagingComponent,
5253
FunctionsComponent,
54+
UpboatsComponent,
5355
],
5456
imports: [
5557
BrowserModule.withServerTransition({ appId: 'serverApp' }),

sample/src/app/home/home.component.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FirebaseApp } from '@angular/fire';
99
<app-database></app-database>
1010
<app-firestore></app-firestore>
1111
<app-firestore-offline></app-firestore-offline>
12+
<app-upboats></app-upboats>
1213
<app-storage></app-storage>
1314
<app-auth></app-auth>
1415
<app-remote-config></app-remote-config>
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
<p>protected-lazy works!</p>
2+
3+
<ul>
4+
<li *ngFor="let item of snapshot | async">
5+
<pre>data(): {{ item.payload.doc.data() | json }},
6+
id: {{ item.payload.doc.id }},
7+
metadata: {{ item.payload.doc.metadata | json }}</pre>
8+
</li>
9+
</ul>

sample/src/app/protected-lazy/protected-lazy.component.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { Component, OnInit } from '@angular/core';
2+
import { DocumentChangeAction } from '@angular/fire/firestore';
3+
import { Observable } from 'rxjs';
4+
import { AngularFirestoreOffline } from '../firestore-offline/firestore-offline.module';
25

36
@Component({
47
selector: 'app-protected-lazy',
@@ -7,7 +10,11 @@ import { Component, OnInit } from '@angular/core';
710
})
811
export class ProtectedLazyComponent implements OnInit {
912

10-
constructor() { }
13+
public snapshot: Observable<DocumentChangeAction<unknown>[]>;
14+
15+
constructor(private afs: AngularFirestoreOffline) {
16+
this.snapshot = afs.collection('test').snapshotChanges();
17+
}
1118

1219
ngOnInit(): void {
1320
}

sample/src/app/upboats/upboats.component.css

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<ul>
2+
<li *ngFor="let animal of animals | async">
3+
<span>{{ animal.name }}</span>
4+
<button (click)="upboat(animal.id)">👍</button>
5+
<span>{{ animal.upboats }}</span>
6+
<button (click)="downboat(animal.id)">👎</button>
7+
<span *ngIf="animal.hasPendingWrites">🕒</span>
8+
</li>
9+
</ul>
10+
11+
<button (click)="newAnimal()">New animal</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { UpboatsComponent } from './upboats.component';
4+
5+
describe('UpboatsComponent', () => {
6+
let component: UpboatsComponent;
7+
let fixture: ComponentFixture<UpboatsComponent>;
8+
9+
beforeEach(async () => {
10+
await TestBed.configureTestingModule({
11+
declarations: [ UpboatsComponent ]
12+
})
13+
.compileComponents();
14+
});
15+
16+
beforeEach(() => {
17+
fixture = TestBed.createComponent(UpboatsComponent);
18+
component = fixture.componentInstance;
19+
fixture.detectChanges();
20+
});
21+
22+
it('should create', () => {
23+
expect(component).toBeTruthy();
24+
});
25+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Component, OnInit } from '@angular/core';
2+
import { Observable } from 'rxjs';
3+
import { map, startWith, tap } from 'rxjs/operators';
4+
import { AngularFirestoreOffline } from '../firestore-offline/firestore-offline.module';
5+
import firebase from 'firebase/app';
6+
import { makeStateKey, TransferState } from '@angular/platform-browser';
7+
import { trace } from '@angular/fire/performance';
8+
9+
type Animal = { name: string, upboats: number, id: string, hasPendingWrites: boolean };
10+
11+
@Component({
12+
selector: 'app-upboats',
13+
templateUrl: './upboats.component.html',
14+
styleUrls: ['./upboats.component.css']
15+
})
16+
export class UpboatsComponent implements OnInit {
17+
18+
public animals: Observable<Animal[]>;
19+
20+
constructor(private firestore: AngularFirestoreOffline, state: TransferState) {
21+
const collection = firestore.collection<Animal>('animals', ref =>
22+
ref.orderBy('upboats', 'desc').orderBy('updatedAt', 'desc')
23+
);
24+
const key = makeStateKey(collection.ref.path);
25+
const existing = state.get(key, undefined);
26+
this.animals = collection.snapshotChanges().pipe(
27+
trace('animals'),
28+
map(it => it.map(change => ({
29+
...change.payload.doc.data(),
30+
id: change.payload.doc.id,
31+
hasPendingWrites: change.payload.doc.metadata.hasPendingWrites
32+
}))),
33+
existing ? startWith(existing) : tap(it => state.set(key, it))
34+
);
35+
}
36+
37+
ngOnInit(): void {
38+
}
39+
40+
upboat(id: string) {
41+
// TODO add rule
42+
this.firestore.doc(`animals/${id}`).update({
43+
upboats: firebase.firestore.FieldValue.increment(1),
44+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
45+
});
46+
}
47+
48+
downboat(id: string) {
49+
// TODO add rule
50+
this.firestore.doc(`animals/${id}`).update({
51+
upboats: firebase.firestore.FieldValue.increment(-1),
52+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
53+
});
54+
}
55+
56+
newAnimal() {
57+
// TODO add rule
58+
this.firestore.collection('animals').add({
59+
name: prompt('Can haz name?'),
60+
upboats: 1,
61+
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
62+
});
63+
}
64+
65+
}

src/firestore/collection/changes.ts

+46-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fromCollectionRef } from '../observable/fromRef';
22
import { Observable, SchedulerLike } from 'rxjs';
3-
import { map, scan } from 'rxjs/operators';
3+
import { distinctUntilChanged, map, pairwise, scan, startWith } from 'rxjs/operators';
44

55
import { DocumentChange, DocumentChangeAction, DocumentChangeType, Query } from '../interfaces';
66

@@ -25,8 +25,29 @@ export function sortedChanges<T>(
2525
scheduler?: SchedulerLike): Observable<DocumentChangeAction<T>[]> {
2626
return fromCollectionRef(query, scheduler)
2727
.pipe(
28-
map(changes => changes.payload.docChanges()),
29-
scan((current, changes) => combineChanges(current, changes, events), []),
28+
startWith(undefined),
29+
pairwise(),
30+
scan((current, [priorChanges, changes]) => {
31+
const docChanges = changes.payload.docChanges();
32+
const ret = combineChanges(current, docChanges, events);
33+
// docChanges({ includeMetadataChanges: true }) does't include metadata changes... wat?
34+
if (events.indexOf('modified') > -1 && priorChanges &&
35+
JSON.stringify(priorChanges.payload.metadata) !== JSON.stringify(changes.payload.metadata)) {
36+
return ret.map(it => {
37+
const partOfDocChanges = !!docChanges.find(d => d.doc.ref.isEqual(it.doc.ref));
38+
return {
39+
// if it's not one of the changed docs that means we already saw it's order change
40+
// so this is purely metadata, so don't move the doc
41+
oldIndex: partOfDocChanges ? it.oldIndex : it.newIndex,
42+
newIndex: it.newIndex,
43+
type: 'modified',
44+
doc: changes.payload.docs.find(d => d.ref.isEqual(it.doc.ref))
45+
};
46+
});
47+
}
48+
return ret;
49+
}, []),
50+
distinctUntilChanged(), // cut down on unneed change cycles
3051
map(changes => changes.map(c => ({ type: c.type, payload: c } as DocumentChangeAction<T>))));
3152
}
3253

@@ -44,6 +65,21 @@ export function combineChanges<T>(current: DocumentChange<T>[], changes: Documen
4465
return current;
4566
}
4667

68+
/**
69+
* Splice arguments on top of a sliced array, to break top-level ===
70+
* this is useful for change-detection
71+
*/
72+
function sliceAndSplice<T>(
73+
original: T[],
74+
start: number,
75+
deleteCount: number,
76+
...args: T[]
77+
): T[] {
78+
const returnArray = original.slice();
79+
returnArray.splice(start, deleteCount, ...args);
80+
return returnArray;
81+
}
82+
4783
/**
4884
* Creates a new sorted array from a new change.
4985
*/
@@ -53,24 +89,26 @@ export function combineChange<T>(combined: DocumentChange<T>[], change: Document
5389
if (combined[change.newIndex] && combined[change.newIndex].doc.ref.isEqual(change.doc.ref)) {
5490
// Not sure why the duplicates are getting fired
5591
} else {
56-
combined.splice(change.newIndex, 0, change);
92+
return sliceAndSplice(combined, change.newIndex, 0, change);
5793
}
5894
break;
5995
case 'modified':
6096
if (combined[change.oldIndex] == null || combined[change.oldIndex].doc.ref.isEqual(change.doc.ref)) {
6197
// When an item changes position we first remove it
6298
// and then add it's new position
6399
if (change.oldIndex !== change.newIndex) {
64-
combined.splice(change.oldIndex, 1);
65-
combined.splice(change.newIndex, 0, change);
100+
const copiedArray = combined.slice();
101+
copiedArray.splice(change.oldIndex, 1);
102+
copiedArray.splice(change.newIndex, 0, change);
103+
return copiedArray;
66104
} else {
67-
combined.splice(change.newIndex, 1, change);
105+
return sliceAndSplice(combined, change.newIndex, 1, change);
68106
}
69107
}
70108
break;
71109
case 'removed':
72110
if (combined[change.oldIndex] && combined[change.oldIndex].doc.ref.isEqual(change.doc.ref)) {
73-
combined.splice(change.oldIndex, 1);
111+
return sliceAndSplice(combined, change.oldIndex, 1);
74112
}
75113
break;
76114
}

0 commit comments

Comments
 (0)