diff --git a/.changeset/cold-brooms-run.md b/.changeset/cold-brooms-run.md new file mode 100644 index 00000000000..acedccf16cb --- /dev/null +++ b/.changeset/cold-brooms-run.md @@ -0,0 +1,5 @@ +--- +'@firebase/analytics': patch +--- + +Analytics - fixed an issue where setConsent was clobbering the consentSettings before passing them to the gtag implementation. diff --git a/.changeset/nine-rings-jog.md b/.changeset/nine-rings-jog.md new file mode 100644 index 00000000000..20a1d8b3d6d --- /dev/null +++ b/.changeset/nine-rings-jog.md @@ -0,0 +1,6 @@ +--- +'@firebase/firestore': patch +'firebase': patch +--- + +Fix multi-tab persistence raising empty snapshot issue diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 6dcedb0b04a..d0c3de16859 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -124,3 +124,35 @@ jobs: curl -X POST -H "Content-Type:application/json" \ -d "{\"version\":\"$BASE_VERSION\",\"date\":\"$DATE\"}" \ $RELEASE_TRACKER_URL/logProduction + - name: Create Github release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get the newest release tag for the firebase package (e.g. firebase@10.12.0) + NEWEST_TAG=$(git describe --tags --match "firebase@[0-9]*.[0-9]*.[0-9]*" --abbrev=0) + + # Get the release notes from the description of the most recent merged PR into the "release" branch + # See: https://github.com/firebase/firebase-js-sdk/pull/8236 for an example description + JSON_RELEASE_NOTES=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state "merged" \ + --base "release" \ + --limit 1 \ + --json "body" \ + | jq '.[].body | split("\n# Releases\n")[-1]' # Remove the generated changesets header + ) + + # Prepend the new release header + # We have to be careful to insert the new release header after a " character, since we're + # modifying the JSON string + JSON_RELEASE_NOTES="\"For more detailed release notes, see [Firebase JavaScript SDK Release Notes](https://firebase.google.com/support/release-notes/js).\n\n# What's Changed\n\n${JSON_RELEASE_NOTES:1}" + + # Format the JSON string into a readable markdown string + RELEASE_NOTES=$(echo -E $JSON_RELEASE_NOTES | jq -r .) + + # Create the GitHub release + gh release create "$NEWEST_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$NEWEST_TAG" \ + --notes "$RELEASE_NOTES" \ + --verify-tag diff --git a/README.md b/README.md index dadbc48ec36..cbac0a604d4 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ To get started using Firebase, see [![Release Notes](https://img.shields.io/npm/v/firebase.svg?style=flat-square&label=Release%20Notes%20for&labelColor=039be5&color=666)](https://firebase.google.com/support/release-notes/js) ## Upgrade to Version 9 + Version 9 has a redesigned API that supports tree-shaking. Read the [Upgrade Guide](https://firebase.google.com/docs/web/modular-upgrade) to learn more. + ## Supported Environments + Please see [Environment Support](https://firebase.google.com/support/guides/environments_js-sdk). ## SDK Dev Workflow @@ -30,7 +33,7 @@ Please see [Environment Support](https://firebase.google.com/support/guides/envi Before you can start working on the Firebase JS SDK, you need to have Node.js installed on your machine. As of April 19th, 2024 the team has been testing with Node.js version -`20.12.2`, but the required verison of Node.js may change as we update our dependencies. +`20.12.2`, but the required version of Node.js may change as we update our dependencies. To download Node.js visit https://nodejs.org/en/download/. @@ -44,7 +47,7 @@ In addition to Node.js we use `yarn` to facilitate multi package development. To install `yarn` follow the instructions listed on their website: https://yarnpkg.com/en/docs/install -This repo currently supports building with yarn `1.x`. For instance, after installating yarn, run +This repo currently supports building with yarn `1.x`. For instance, after installing yarn, run ```bash $ yarn set version 1.22.11 ``` @@ -204,7 +207,7 @@ In order to manually test your SDK changes locally, you must use [yarn link](htt ```shell $ cd packages/firebase $ yarn link # initialize the linking to the other folder -$ cd ../packages/ # Example: $ cd packages/database +$ cd ../ # Example: $ cd ../firestore $ yarn link # link your product to make it available elsewhere $ cd # cd into your personal project directory $ yarn link firebase @firebase/ # tell yarn to use the locally built firebase SDK instead diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index b35a3f6867b..ff06ba3ea6c 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -175,10 +175,11 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { + const eventObject = { 'transaction_id': 'abcd123', 'send_to': 'some_group' - }); + }; + (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(fakeMeasurementId); // Resolves first initialization promise. @@ -187,8 +188,12 @@ describe('Gtag wrapping functions', () => { initPromise2.resolve('other-measurement-id'); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() await promiseAllSettled(fakeDynamicConfigPromises); - - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('event'); + expect(data[1]).to.equal('purchase'); + expect(data[2]).to.equal(eventObject); }); it( @@ -208,10 +213,11 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { + const eventObject = { 'transaction_id': 'abcd123', 'send_to': [fakeMeasurementId, 'some_group'] - }); + }; + (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(); // Resolves first initialization promise. @@ -221,7 +227,12 @@ describe('Gtag wrapping functions', () => { await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() await promiseAllSettled(fakeDynamicConfigPromises); - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('event'); + expect(data[1]).to.equal('purchase'); + expect(data[2]).to.equal(eventObject); } ); @@ -242,9 +253,10 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { + const eventObject = { 'transaction_id': 'abcd123' - }); + }; + (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(); // Resolves first initialization promise. @@ -253,7 +265,12 @@ describe('Gtag wrapping functions', () => { initPromise2.resolve(); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('event'); + expect(data[1]).to.equal('purchase'); + expect(data[2]).to.equal(eventObject); } ); @@ -274,17 +291,23 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { + const eventObject = { 'transaction_id': 'abcd123', 'send_to': fakeMeasurementId - }); + }; + (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(); // Resolves first initialization promise. await promiseAllSettled(fakeDynamicConfigPromises); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('event'); + expect(data[1]).to.equal('purchase'); + expect(data[2]).to.equal(eventObject); } ); @@ -307,8 +330,13 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.SET, { 'language': 'en' }); - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const eventObject = { 'language': 'en' }; + (window['gtag'] as Gtag)(GtagCommand.SET, eventObject); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('set'); + expect(data[1]).to.equal(eventObject); }); it('new window.gtag function does not wait when sending "consent" calls', async () => { @@ -329,7 +357,12 @@ describe('Gtag wrapping functions', () => { 'update', consentParameters ); - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('consent'); + expect(data[1]).to.equal('update'); + expect(data[2]).to.equal(consentParameters); }); it('new window.gtag function does not wait when sending "get" calls', async () => { @@ -347,7 +380,13 @@ describe('Gtag wrapping functions', () => { 'client_id', clientId => console.log(clientId) ); - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('get'); + expect(data[1]).to.equal(fakeMeasurementId); + expect(data[2]).to.equal('client_id'); + expect(data[3]).to.not.be.undefined; }); it('new window.gtag function does not wait when sending an unknown command', async () => { @@ -360,7 +399,11 @@ describe('Gtag wrapping functions', () => { ); window['dataLayer'] = []; (window['gtag'] as Gtag)('new-command-from-gtag-team', fakeMeasurementId); - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('new-command-from-gtag-team'); + expect(data[1]).to.equal(fakeMeasurementId); }); it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { @@ -373,9 +416,14 @@ describe('Gtag wrapping functions', () => { 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { + const eventObject = { 'language': 'en' - }); + }; + (window['gtag'] as Gtag)( + GtagCommand.CONFIG, + fakeMeasurementId, + eventObject + ); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(fakeMeasurementId); @@ -383,19 +431,33 @@ describe('Gtag wrapping functions', () => { expect((window['dataLayer'] as DataLayer).length).to.equal(0); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() - - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('config'); + expect(data[1]).to.equal(fakeMeasurementId); + expect(data[2]).to.equal(eventObject); }); it('new window.gtag function does not wait when sending "config" calls if there are no pending initialization promises', async () => { wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { + const eventObject = { 'transaction_id': 'abcd123' - }); + }; + (window['gtag'] as Gtag)( + GtagCommand.CONFIG, + fakeMeasurementId, + eventObject + ); await promiseAllSettled(fakeDynamicConfigPromises); await Promise.resolve(); // Config call is always chained onto initialization promise list, even if empty. - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + const dataLayer = window['dataLayer'] as DataLayer; + expect(dataLayer.length).to.equal(1); + const data = dataLayer[0]; + expect(data[0]).to.equal('config'); + expect(data[1]).to.equal(fakeMeasurementId); + expect(data[2]).to.equal(eventObject); }); }); diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index 7f9582900c2..2e9a46e03b2 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -304,8 +304,13 @@ function wrapGtag( gtagParams as GtagConfigOrEventParams ); } else if (command === GtagCommand.CONSENT) { - const [gtagParams] = args; - gtagCore(GtagCommand.CONSENT, 'update', gtagParams as ConsentSettings); + const [consentAction, gtagParams] = args; + // consentAction can be one of 'default' or 'update'. + gtagCore( + GtagCommand.CONSENT, + consentAction, + gtagParams as ConsentSettings + ); } else if (command === GtagCommand.GET) { const [measurementId, fieldName, callback] = args; gtagCore( diff --git a/packages/firestore/src/core/sync_engine_impl.ts b/packages/firestore/src/core/sync_engine_impl.ts index f4db6f4a5bd..bba07f4f4bc 100644 --- a/packages/firestore/src/core/sync_engine_impl.ts +++ b/packages/firestore/src/core/sync_engine_impl.ts @@ -1095,9 +1095,10 @@ export async function syncEngineEmitNewSnapsAndNotifyLocalStore( // secondary clients to update query state. if (viewSnapshot || remoteEvent) { if (syncEngineImpl.isPrimaryClient) { + const isCurrent = viewSnapshot && !viewSnapshot.fromCache; syncEngineImpl.sharedClientState.updateQueryState( queryView.targetId, - viewSnapshot?.fromCache ? 'not-current' : 'current' + isCurrent ? 'current' : 'not-current' ); } } diff --git a/packages/firestore/test/unit/specs/query_spec.test.ts b/packages/firestore/test/unit/specs/query_spec.test.ts index 5e9736db95b..9046540a2ff 100644 --- a/packages/firestore/test/unit/specs/query_spec.test.ts +++ b/packages/firestore/test/unit/specs/query_spec.test.ts @@ -24,7 +24,7 @@ import { Document } from '../../../src/model/document'; import { doc, filter, query } from '../../util/helpers'; import { describeSpec, specTest } from './describe_spec'; -import { spec, SpecBuilder } from './spec_builder'; +import { client, spec, SpecBuilder } from './spec_builder'; // Helper to seed the cache with the specified docs by listening to each one. function specWithCachedDocs(...docs: Document[]): SpecBuilder { @@ -136,4 +136,42 @@ describeSpec('Queries:', [], () => { ); } ); + + specTest( + 'Queries in different tabs will not interfere', + ['multi-client'], + () => { + const query1 = query('collection', filter('key', '==', 'a')); + const query2 = query('collection', filter('key', '==', 'b')); + const docA = doc('collection/a', 1000, { key: 'a' }); + const docB = doc('collection/b', 1000, { key: 'b' }); + + return ( + client(0) + .becomeVisible() + // Listen to the first query in the primary client + .expectPrimaryState(true) + .userListens(query1) + .watchAcks(query1) + .watchSends({ affects: [query1] }, docA) + + // Listen to different query in the secondary client + .client(1) + .userListens(query2) + + .client(0) + .expectListen(query2) + .watchCurrents(query1, 'resume-token-1000') + // Receive global snapshot before the second query is acknowledged + .watchSnapshots(1000) + .expectEvents(query1, { added: [docA] }) + // This should not trigger empty snapshot for second query(bugged behavior) + .client(1) + .client(0) + .watchAcksFull(query2, 2000, docB) + .client(1) + .expectEvents(query2, { added: [docB] }) + ); + } + ); });