Skip to content

Commit

Permalink
Fix Firestore failing to return empty results from the local cache (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
milaGGL committed Oct 7, 2022
1 parent 397317b commit 0a112bd
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 29 deletions.
6 changes: 6 additions & 0 deletions .changeset/cool-grapes-attend.md
@@ -0,0 +1,6 @@
---
'@firebase/firestore': patch
'firebase': patch
---

Fix Firestore failing to raise initial snapshot from empty local cache result
15 changes: 11 additions & 4 deletions packages/firestore/src/core/event_manager.ts
Expand Up @@ -307,7 +307,8 @@ export class QueryListener {
snap.mutatedKeys,
snap.fromCache,
snap.syncStateChanged,
/* excludesMetadataChanges= */ true
/* excludesMetadataChanges= */ true,
snap.hasCachedResults
);
}
let raisedEvent = false;
Expand Down Expand Up @@ -371,8 +372,13 @@ export class QueryListener {
return false;
}

// Raise data from cache if we have any documents or we are offline
return !snap.docs.isEmpty() || onlineState === OnlineState.Offline;
// Raise data from cache if we have any documents, have cached results before,
// or we are offline.
return (
!snap.docs.isEmpty() ||
snap.hasCachedResults ||
onlineState === OnlineState.Offline
);
}

private shouldRaiseEvent(snap: ViewSnapshot): boolean {
Expand Down Expand Up @@ -405,7 +411,8 @@ export class QueryListener {
snap.query,
snap.docs,
snap.mutatedKeys,
snap.fromCache
snap.fromCache,
snap.hasCachedResults
);
this.raisedInitialEvent = true;
this.queryObserver.next(snap);
Expand Down
19 changes: 13 additions & 6 deletions packages/firestore/src/core/sync_engine_impl.ts
Expand Up @@ -64,6 +64,7 @@ import {
import { debugAssert, debugCast, fail, hardAssert } from '../util/assert';
import { wrapInUserErrorIfRecoverable } from '../util/async_queue';
import { BundleReader } from '../util/bundle_reader';
import { ByteString } from '../util/byte_string';
import { Code, FirestoreError } from '../util/error';
import { logDebug, logWarn } from '../util/log';
import { primitiveComparator } from '../util/misc';
Expand Down Expand Up @@ -327,7 +328,8 @@ export async function syncEngineListen(
syncEngineImpl,
query,
targetId,
status === 'current'
status === 'current',
targetData.resumeToken
);
}

Expand All @@ -342,7 +344,8 @@ async function initializeViewAndComputeSnapshot(
syncEngineImpl: SyncEngineImpl,
query: Query,
targetId: TargetId,
current: boolean
current: boolean,
resumeToken: ByteString
): Promise<ViewSnapshot> {
// PORTING NOTE: On Web only, we inject the code that registers new Limbo
// targets based on view changes. This allows us to only depend on Limbo
Expand All @@ -360,7 +363,8 @@ async function initializeViewAndComputeSnapshot(
const synthesizedTargetChange =
TargetChange.createSynthesizedTargetChangeForCurrentChange(
targetId,
current && syncEngineImpl.onlineState !== OnlineState.Offline
current && syncEngineImpl.onlineState !== OnlineState.Offline,
resumeToken
);
const viewChange = view.applyChanges(
viewDocChanges,
Expand Down Expand Up @@ -1385,7 +1389,8 @@ async function synchronizeQueryViewsAndRaiseSnapshots(
syncEngineImpl,
synthesizeTargetToQuery(target!),
targetId,
/*current=*/ false
/*current=*/ false,
targetData.resumeToken
);
}

Expand Down Expand Up @@ -1457,7 +1462,8 @@ export async function syncEngineApplyTargetState(
const synthesizedRemoteEvent =
RemoteEvent.createSynthesizedRemoteEventForCurrentChange(
targetId,
state === 'current'
state === 'current',
ByteString.EMPTY_BYTE_STRING
);
await syncEngineEmitNewSnapsAndNotifyLocalStore(
syncEngineImpl,
Expand Down Expand Up @@ -1512,7 +1518,8 @@ export async function syncEngineApplyActiveTargetsChange(
syncEngineImpl,
synthesizeTargetToQuery(target),
targetData.targetId,
/*current=*/ false
/*current=*/ false,
targetData.resumeToken
);
remoteStoreListen(syncEngineImpl.remoteStore, targetData);
}
Expand Down
9 changes: 7 additions & 2 deletions packages/firestore/src/core/view.ts
Expand Up @@ -72,6 +72,7 @@ export interface ViewChange {
*/
export class View {
private syncState: SyncState | null = null;
private hasCachedResults: boolean = false;
/**
* A flag whether the view is current with the backend. A view is considered
* current after it has seen the current flag from the backend and did not
Expand Down Expand Up @@ -319,7 +320,10 @@ export class View {
docChanges.mutatedKeys,
newSyncState === SyncState.Local,
syncStateChanged,
/* excludesMetadataChanges= */ false
/* excludesMetadataChanges= */ false,
targetChange
? targetChange.resumeToken.approximateByteSize() > 0
: false
);
return {
snapshot: snap,
Expand Down Expand Up @@ -468,7 +472,8 @@ export class View {
this.query,
this.documentSet,
this.mutatedKeys,
this.syncState === SyncState.Local
this.syncState === SyncState.Local,
this.hasCachedResults
);
}
}
Expand Down
10 changes: 7 additions & 3 deletions packages/firestore/src/core/view_snapshot.ts
Expand Up @@ -146,15 +146,17 @@ export class ViewSnapshot {
readonly mutatedKeys: DocumentKeySet,
readonly fromCache: boolean,
readonly syncStateChanged: boolean,
readonly excludesMetadataChanges: boolean
readonly excludesMetadataChanges: boolean,
readonly hasCachedResults: boolean
) {}

/** Returns a view snapshot as if all documents in the snapshot were added. */
static fromInitialDocuments(
query: Query,
documents: DocumentSet,
mutatedKeys: DocumentKeySet,
fromCache: boolean
fromCache: boolean,
hasCachedResults: boolean
): ViewSnapshot {
const changes: DocumentViewChange[] = [];
documents.forEach(doc => {
Expand All @@ -169,7 +171,8 @@ export class ViewSnapshot {
mutatedKeys,
fromCache,
/* syncStateChanged= */ true,
/* excludesMetadataChanges= */ false
/* excludesMetadataChanges= */ false,
hasCachedResults
);
}

Expand All @@ -180,6 +183,7 @@ export class ViewSnapshot {
isEqual(other: ViewSnapshot): boolean {
if (
this.fromCache !== other.fromCache ||
this.hasCachedResults !== other.hasCachedResults ||
this.syncStateChanged !== other.syncStateChanged ||
!this.mutatedKeys.isEqual(other.mutatedKeys) ||
!queryEquals(this.query, other.query) ||
Expand Down
11 changes: 7 additions & 4 deletions packages/firestore/src/remote/remote_event.ts
Expand Up @@ -67,14 +67,16 @@ export class RemoteEvent {
// PORTING NOTE: Multi-tab only
static createSynthesizedRemoteEventForCurrentChange(
targetId: TargetId,
current: boolean
current: boolean,
resumeToken: ByteString
): RemoteEvent {
const targetChanges = new Map<TargetId, TargetChange>();
targetChanges.set(
targetId,
TargetChange.createSynthesizedTargetChangeForCurrentChange(
targetId,
current
current,
resumeToken
)
);
return new RemoteEvent(
Expand Down Expand Up @@ -134,10 +136,11 @@ export class TargetChange {
*/
static createSynthesizedTargetChangeForCurrentChange(
targetId: TargetId,
current: boolean
current: boolean,
resumeToken: ByteString
): TargetChange {
return new TargetChange(
ByteString.EMPTY_BYTE_STRING,
resumeToken,
current,
documentKeySet(),
documentKeySet(),
Expand Down
41 changes: 40 additions & 1 deletion packages/firestore/test/integration/api/query.test.ts
Expand Up @@ -1281,13 +1281,52 @@ apiDescribe('Queries', (persistence: boolean) => {
};

return withTestCollection(persistence, testDocs, async coll => {
await getDocs(query(coll)); // Populate the cache
await getDocs(query(coll)); // Populate the cache.
const snapshot = await getDocs(
query(coll, where('map.nested', '==', 'foo'))
);
expect(toDataArray(snapshot)).to.deep.equal([{ map: { nested: 'foo' } }]);
});
});

// Reproduces https://github.com/firebase/firebase-js-sdk/issues/5873
// eslint-disable-next-line no-restricted-properties
(persistence ? describe : describe.skip)('Caching empty results', () => {
it('can raise initial snapshot from cache, even if it is empty', () => {
return withTestCollection(persistence, {}, async coll => {
const snapshot1 = await getDocs(coll); // Populate the cache.
expect(snapshot1.metadata.fromCache).to.be.false;
expect(toDataArray(snapshot1)).to.deep.equal([]); // Precondition check.

// Add a snapshot listener whose first event should be raised from cache.
const storeEvent = new EventsAccumulator<QuerySnapshot>();
onSnapshot(coll, storeEvent.storeEvent);
const snapshot2 = await storeEvent.awaitEvent();
expect(snapshot2.metadata.fromCache).to.be.true;
expect(toDataArray(snapshot2)).to.deep.equal([]);
});
});

it('can raise initial snapshot from cache, even if it has become empty', () => {
const testDocs = {
a: { key: 'a' }
};
return withTestCollection(persistence, testDocs, async coll => {
// Populate the cache.
const snapshot1 = await getDocs(coll);
expect(snapshot1.metadata.fromCache).to.be.false;
expect(toDataArray(snapshot1)).to.deep.equal([{ key: 'a' }]);
// Empty the collection.
void deleteDoc(doc(coll, 'a'));

const storeEvent = new EventsAccumulator<QuerySnapshot>();
onSnapshot(coll, storeEvent.storeEvent);
const snapshot2 = await storeEvent.awaitEvent();
expect(snapshot2.metadata.fromCache).to.be.true;
expect(toDataArray(snapshot2)).to.deep.equal([]);
});
});
});
});

function verifyDocumentChange<T>(
Expand Down
45 changes: 45 additions & 0 deletions packages/firestore/test/unit/api/database.test.ts
Expand Up @@ -177,6 +177,51 @@ describe('QuerySnapshot', () => {
querySnapshot('foo', {}, { a: { a: 1 } }, keys('foo/a'), false, true)
)
).to.be.false;
// hasCachedResults should affect querySnapshot equality
expect(
snapshotEqual(
querySnapshot(
'foo',
{},
{ a: { a: 1 } },
keys('foo/a'),
false,
false,
true
),
querySnapshot(
'foo',
{},
{ a: { a: 1 } },
keys('foo/a'),
false,
false,
true
)
)
).to.be.true;
expect(
snapshotEqual(
querySnapshot(
'foo',
{},
{ a: { a: 1 } },
keys('foo/a'),
false,
false,
true
),
querySnapshot(
'foo',
{},
{ a: { a: 1 } },
keys('foo/a'),
false,
false,
false
)
)
).to.be.false;
});

it('JSON.stringify() does not throw', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/firestore/test/unit/core/event_manager.test.ts
Expand Up @@ -224,7 +224,8 @@ describe('QueryListener', () => {
docChanges: [change1, change4],
fromCache: snap2.fromCache,
syncStateChanged: true,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap2.hasCachedResults
};
expect(otherEvents).to.deep.equal([expectedSnap2]);
});
Expand Down Expand Up @@ -396,7 +397,8 @@ describe('QueryListener', () => {
docChanges: [change3],
fromCache: snap2.fromCache,
syncStateChanged: snap2.syncStateChanged,
mutatedKeys: snap2.mutatedKeys
mutatedKeys: snap2.mutatedKeys,
hasCachedResults: snap2.hasCachedResults
};
expect(filteredEvents).to.deep.equal([snap1, expectedSnap2]);
}
Expand Down Expand Up @@ -482,7 +484,8 @@ describe('QueryListener', () => {
],
fromCache: false,
syncStateChanged: true,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap3.hasCachedResults
};
expect(events).to.deep.equal([expectedSnap]);
});
Expand Down Expand Up @@ -517,7 +520,8 @@ describe('QueryListener', () => {
docChanges: [{ type: ChangeType.Added, doc: doc1 }],
fromCache: true,
syncStateChanged: true,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap1.hasCachedResults
};
const expectedSnap2 = {
query: query1,
Expand All @@ -526,7 +530,8 @@ describe('QueryListener', () => {
docChanges: [{ type: ChangeType.Added, doc: doc2 }],
fromCache: true,
syncStateChanged: false,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap2.hasCachedResults
};
expect(events).to.deep.equal([expectedSnap1, expectedSnap2]);
});
Expand All @@ -552,7 +557,8 @@ describe('QueryListener', () => {
docChanges: [],
fromCache: true,
syncStateChanged: true,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap1.hasCachedResults
};
expect(events).to.deep.equal([expectedSnap]);
});
Expand All @@ -577,7 +583,8 @@ describe('QueryListener', () => {
docChanges: [],
fromCache: true,
syncStateChanged: true,
mutatedKeys: keys()
mutatedKeys: keys(),
hasCachedResults: snap1.hasCachedResults
};
expect(events).to.deep.equal([expectedSnap]);
});
Expand Down

0 comments on commit 0a112bd

Please sign in to comment.