Skip to content

Commit ce3de04

Browse files
committed
fix(list): Work around Firebase SDK sync quirk #574
1 parent 70a3e94 commit ce3de04

File tree

5 files changed

+102
-57
lines changed

5 files changed

+102
-57
lines changed

karma.conf.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = function(config) {
3333
colors: true,
3434
logLevel: config.LOG_INFO,
3535
autoWatch: true,
36+
reporters: ['mocha'],
3637
browsers: ['Chrome'],
3738
singleRun: false
3839
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"test": "npm run build && karma start --single-run",
99
"test:watch": "concurrently \"npm run build:watch\" \"npm run delayed_karma\"",
10+
"test:debug": "npm run build && karma start",
1011
"delayed_karma": "sleep 10 && karma start",
1112
"delayed_rollup": "sleep 5 && rollup --watch -c rollup.test.config.js",
1213
"build:watch": "rm -rf dist && concurrently \"tsc -w\" \"npm run delayed_rollup\"",

src/database/firebase_list_factory.spec.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,8 @@ describe('FirebaseListFactory', () => {
364364
firebase.database().ref().remove(done);
365365
questions = FirebaseListFactory(`${rootDatabaseUrl}/questions`);
366366
questionsSnapshotted = FirebaseListFactory(`${rootDatabaseUrl}/questionssnapshot`, { preserveSnapshot: true });
367-
ref = (<any>questions).$ref;
368-
refSnapshotted = (<any>questionsSnapshotted).$ref;
367+
ref = questions.$ref;
368+
refSnapshotted = questionsSnapshotted.$ref;
369369
});
370370

371371
afterEach((done: any) => {
@@ -377,7 +377,7 @@ describe('FirebaseListFactory', () => {
377377

378378

379379
it('should emit only when the initial data set has been loaded', (done: any) => {
380-
(<any>questions).$ref.set([{ initial1: true }, { initial2: true }, { initial3: true }, { initial4: true }])
380+
questions.$ref.ref.set([{ initial1: true }, { initial2: true }, { initial3: true }, { initial4: true }])
381381
.then(() => toPromise.call(skipAndTake(questions, 1)))
382382
.then((val: any[]) => {
383383
expect(val.length).toBe(4);
@@ -434,13 +434,15 @@ describe('FirebaseListFactory', () => {
434434
});
435435

436436

437-
it('should call off on all events when disposed', () => {
437+
it('should call off on all events when disposed', (done: any) => {
438438
const questionRef = firebase.database().ref().child('questions');
439439
var firebaseSpy = spyOn(questionRef, 'off').and.callThrough();
440-
subscription = FirebaseListFactory(questionRef).subscribe();
441-
expect(firebaseSpy).not.toHaveBeenCalled();
442-
subscription.unsubscribe();
443-
expect(firebaseSpy).toHaveBeenCalled();
440+
subscription = FirebaseListFactory(questionRef).subscribe(_ => {
441+
expect(firebaseSpy).not.toHaveBeenCalled();
442+
subscription.unsubscribe();
443+
expect(firebaseSpy).toHaveBeenCalled();
444+
done();
445+
});
444446
});
445447

446448

src/database/firebase_list_factory.ts

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -100,58 +100,99 @@ export function FirebaseListFactory (
100100
});
101101
}
102102

103+
/**
104+
* Creates a FirebaseListObservable from a reference or query. Options can be provided as a second parameter.
105+
* This function understands the nuances of the Firebase SDK event ordering and other quirks. This function
106+
* takes into account that not all .on() callbacks are guaranteed to be asynchonous. It creates a initial array
107+
* from a promise of ref.once('value'), and then starts listening to child events. When the initial array
108+
* is loaded, the observable starts emitting values.
109+
*/
103110
function firebaseListObservable(ref: firebase.database.Reference | firebase.database.Query, {preserveSnapshot}: FirebaseListFactoryOpts = {}): FirebaseListObservable<any> {
104-
111+
// Keep track of callback handles for calling ref.off(event, handle)
112+
const handles = [];
105113
const listObs = new FirebaseListObservable(ref, (obs: Observer<any[]>) => {
106-
let arr: any[] = [];
107-
let hasInitialLoad = false;
108-
// The list should only emit after the initial load
109-
// comes down from the Firebase database, (e.g.) all
110-
// the initial child_added events have fired.
111-
// This way a complete array is emitted which leads
112-
// to better rendering performance
113-
ref.once('value', (snap) => {
114-
hasInitialLoad = true;
115-
obs.next(preserveSnapshot ? arr : arr.map(utils.unwrapMapFn));
116-
}).catch(err => {
117-
obs.error(err);
118-
obs.complete()
119-
});
120-
121-
let addFn = ref.on('child_added', (child: any, prevKey: string) => {
122-
arr = onChildAdded(arr, child, prevKey);
123-
// only emit the array after the initial load
124-
if (hasInitialLoad) {
125-
obs.next(preserveSnapshot ? arr : arr.map(utils.unwrapMapFn));
126-
}
127-
}, err => {
128-
if (err) { obs.error(err); obs.complete(); }
129-
});
130-
131-
let remFn = ref.on('child_removed', (child: any) => {
132-
arr = onChildRemoved(arr, child)
133-
if (hasInitialLoad) {
134-
obs.next(preserveSnapshot ? arr : arr.map(utils.unwrapMapFn));
135-
}
136-
}, err => {
137-
if (err) { obs.error(err); obs.complete(); }
138-
});
139-
140-
let chgFn = ref.on('child_changed', (child: any, prevKey: string) => {
141-
arr = onChildChanged(arr, child, prevKey)
142-
if (hasInitialLoad) {
143-
// This also manages when the only change is prevKey change
144-
obs.next(preserveSnapshot ? arr : arr.map(utils.unwrapMapFn));
145-
}
146-
}, err => {
147-
if (err) { obs.error(err); obs.complete(); }
148-
});
114+
ref.once('value')
115+
.then((snap) => {
116+
let initialArray = [];
117+
snap.forEach(child => {
118+
initialArray.push(child)
119+
});
120+
return initialArray;
121+
})
122+
.then((initialArray) => {
123+
const isInitiallyEmpty = initialArray.length === 0;
124+
let hasInitialLoad = false;
125+
let lastKey;
126+
127+
if (!isInitiallyEmpty) {
128+
// The last key in the initial array tells us where
129+
// to begin listening in realtime
130+
lastKey = initialArray[initialArray.length - 1].key;
131+
}
132+
133+
const addFn = ref.on('child_added', (child: any, prevKey: string) => {
134+
// If the initial load has not been set and the current key is
135+
// the last key of the initialArray, we know we have hit the
136+
// initial load
137+
if (!isInitiallyEmpty) {
138+
if (child.key === lastKey) {
139+
hasInitialLoad = true;
140+
obs.next(preserveSnapshot ? initialArray : initialArray.map(utils.unwrapMapFn));
141+
return;
142+
}
143+
}
144+
145+
if (hasInitialLoad) {
146+
initialArray = onChildAdded(initialArray, child, prevKey);
147+
}
148+
149+
// only emit the array after the initial load
150+
if (hasInitialLoad) {
151+
obs.next(preserveSnapshot ? initialArray : initialArray.map(utils.unwrapMapFn));
152+
}
153+
}, err => {
154+
if (err) { obs.error(err); obs.complete(); }
155+
});
156+
157+
handles.push({ event: 'child_added', handle: addFn });
158+
159+
let remFn = ref.on('child_removed', (child: any) => {
160+
initialArray = onChildRemoved(initialArray, child)
161+
if (hasInitialLoad) {
162+
obs.next(preserveSnapshot ? initialArray : initialArray.map(utils.unwrapMapFn));
163+
}
164+
}, err => {
165+
if (err) { obs.error(err); obs.complete(); }
166+
});
167+
handles.push({ event: 'child_removed', handle: remFn });
168+
169+
let chgFn = ref.on('child_changed', (child: any, prevKey: string) => {
170+
initialArray = onChildChanged(initialArray, child, prevKey)
171+
if (hasInitialLoad) {
172+
// This also manages when the only change is prevKey change
173+
obs.next(preserveSnapshot ? initialArray : initialArray.map(utils.unwrapMapFn));
174+
}
175+
}, err => {
176+
if (err) { obs.error(err); obs.complete(); }
177+
});
178+
handles.push({ event: 'child_changed', handle: chgFn });
179+
180+
// If empty emit the array
181+
if (isInitiallyEmpty) {
182+
obs.next(initialArray);
183+
hasInitialLoad = true;
184+
}
185+
});
149186

150187
return () => {
151-
ref.off('child_added', addFn);
152-
ref.off('child_removed', remFn);
153-
ref.off('child_changed', chgFn);
154-
}
188+
// Loop through callback handles and dispose of each event with handle
189+
// The Firebase SDK requires the reference, event name, and callback to
190+
// properly unsubscribe, otherwise it can affect other subscriptions.
191+
handles.forEach(item => {
192+
ref.off(item.event, item.handle);
193+
});
194+
};
195+
155196
});
156197

157198
// TODO: should be in the subscription zone instead

src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as firebase from 'firebase';
22
import { Subscription } from 'rxjs/Subscription';
33
import { Scheduler } from 'rxjs/Scheduler';
44
import { queue } from 'rxjs/scheduler/queue';
5-
import { AFUnwrappedDataSnapshot} from './interfaces';
5+
import { AFUnwrappedDataSnapshot } from './interfaces';
66

77
export function isNil(obj: any): boolean {
88
return obj === undefined || obj === null;

0 commit comments

Comments
 (0)