Repository: duckduckgo/content-scope-scripts
Pull request #1677: ntp: fixed the empty state of activity widget when trackers are present
Author: login: shakyShane
Created: 2025-05-10T19:20:06Z | Merged: 2025-05-12T08:37:53Z

**Asana Task/Github Issue:** https://app.asana.com/1/137249556945/project/1207414201589134/task/1210188026604205?focus=true

## Description

- ensure the 'empty' state following the last removal still shows the tracker count

## Testing Steps

- visit https://deploy-preview-1677--content-scope-scripts.netlify.app/build/pages/new-tab/?feed=activity&activity=singleWithTrackers&platform=windows
- Click the 'x' at the side of the only activity item
- when removed, the heading "56 tracking attempts blocked" should remain

![image](https://github.com/user-attachments/assets/e3637375-f51d-42f3-a47d-e1a8abe10141)


## Checklist

<!--
  These questions are a friendly reminder to shipping code, if you're uncertain ask the AoR owners.
  It's also totally appropriate to not check some of these boxes, if they don't apply to your change.
-->
*Please tick all that apply:*

- [ ] I have tested this change locally
- [ ] I have tested this change locally in all supported browsers
- [ ] This change will be visible to users
- [ ] I have added automated tests that cover this change
- [ ] I have ensured the change is gated by config
- [ ] This change was covered by a ship review
- [ ] This change was covered by a tech design
- [ ] Any dependent config has been merged



---
- To see the specific tasks where the Asana app for GitHub is being used, see below:
  - https://app.asana.com/0/0/1210214336847290


Diff:
diff --git a/special-pages/pages/new-tab/app/activity/components/Activity.examples.js b/special-pages/pages/new-tab/app/activity/components/Activity.examples.js
index c6e1001c37a..2a5075407ff 100644
--- a/special-pages/pages/new-tab/app/activity/components/Activity.examples.js
+++ b/special-pages/pages/new-tab/app/activity/components/Activity.examples.js
@@ -31,6 +31,15 @@ export const activityExamples = {
             </Activity>
         ),
     },
+    'activity.noActivity.someTrackers': {
+        factory: () => (
+            <Activity expansion={'collapsed'} itemCount={0} trackerCount={56} toggle={noop('toggle')} batched={false}>
+                <Mock size={0}>
+                    <ActivityBody canBurn={false} visibility={'visible'} />
+                </Mock>
+            </Activity>
+        ),
+    },
 };
 
 /**
diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js b/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js
index e56218eddd8..1a6673c6786 100644
--- a/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js
+++ b/special-pages/pages/new-tab/app/activity/integration-tests/activity.page.js
@@ -426,4 +426,13 @@ export class ActivityPage {
             - paragraph: Past 7 days
         `);
     }
+
+    async hasTrackingInfoWithoutButtons() {
+        const { page } = this;
+        await expect(page.getByTestId('ActivityHeading')).toMatchAriaSnapshot(`
+          - img "Privacy Shield"
+          - heading "56 tracking attempts blocked" [level=2]
+          - paragraph: Past 7 days
+      `);
+    }
 }
diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js b/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js
index 37fbb1e8742..ad7b621f053 100644
--- a/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js
+++ b/special-pages/pages/new-tab/app/activity/integration-tests/activity.spec.js
@@ -219,9 +219,35 @@ test.describe('activity widget', () => {
             });
 
             await widget.hasRows(5);
-            await batching.removesItem(0);
+            await batching.itemRemovedViaPatch(0);
             await widget.hasRows(4);
         });
+        test('patching removes last item and maintains tracker count', async ({ page }, workerInfo) => {
+            test.info().annotations.push({
+                type: 'link',
+                description: 'https://app.asana.com/1/137249556945/project/1207414201589134/task/1210188026604205?focus=true',
+            });
+            test.info().annotations.push({
+                type: 'info',
+                description: `
+                    This test simulates removing the last item in the list. When that occurs, the tracker count
+                    should remain if it was greater than zero. The bug here was that we'd reset back to 'no tracking activity'.
+                `,
+            });
+            const ntp = NewtabPage.create(page, workerInfo);
+            await ntp.reducedMotion();
+
+            const widget = new ActivityPage(page, ntp);
+            const batching = new BatchingPage(page, ntp, widget);
+
+            await ntp.openPage({
+                additional: { feed: 'activity', 'activity.api': 'batched', platform: 'windows', activity: 'singleWithTrackers' },
+            });
+
+            await widget.hasRows(1);
+            await batching.removesItem({ index: 0, nextTrackerCount: 56 }); // from activity/mocks/activity.mocks.js
+            await widget.hasTrackingInfoWithoutButtons();
+        });
         test('items are fetched to replace patched removals', async ({ page }, workerInfo) => {
             const ntp = NewtabPage.create(page, workerInfo);
             await ntp.reducedMotion();
diff --git a/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js b/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js
index 98d95efad1a..fc1e4e3a184 100644
--- a/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js
+++ b/special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js
@@ -106,13 +106,28 @@ export class BatchingPage {
      *
      * @param {number} index - The index of the item to remove from the sample data array.
      */
-    async removesItem(index) {
+    async itemRemovedViaPatch(index) {
         const data = generateSampleData(this.ap.entries);
         data.splice(index, 1);
         const update = toPatch(data);
         await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), update);
     }
 
+    /**
+     * Simulates removing all items
+     * @param {object} params
+     * @param {number} params.index - The index of the item to remove from the sample data array.
+     * @param {number} params.nextTrackerCount
+     */
+    async removesItem({ index, nextTrackerCount }) {
+        const { page } = this;
+        await page.locator('button[data-action="remove"]').nth(index).click();
+
+        const update = toPatch([]);
+        update.totalTrackersBlocked = nextTrackerCount;
+        await this.ntp.mocks.simulateSubscriptionMessage(sub('activity_onDataPatch'), update);
+    }
+
     async fillsHoleWhenItemRemoved() {
         if (this.ap.entries !== 6) throw new Error('this scenario expects 6 initial items');
 
@@ -131,7 +146,7 @@ export class BatchingPage {
         });
 
         // now remove the first item via subscription
-        await this.removesItem(0);
+        await this.itemRemovedViaPatch(0);
 
         // now there must have been 2 calls
         const [, second] = await this.ntp.mocks.waitForCallCount({ method: 'activity_getDataForUrls', count: 2 });
diff --git a/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js b/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js
index 88dc7f37ae3..0c7210ce27b 100644
--- a/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js
+++ b/special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js
@@ -36,6 +36,24 @@ export function activityMockTransport() {
             /** @type {import('../../../types/new-tab.ts').NewTabMessages['notifications']} */
             const msg = /** @type {any} */ (_msg);
             switch (msg.method) {
+                case 'activity_removeItem': {
+                    // grab the tracker count of the current dataset before we alter it
+                    const oldCount = dataset.activity.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0);
+
+                    // now filter the items
+                    dataset.activity = dataset.activity.filter((x) => x.url !== msg.params.url);
+
+                    // create the patch dataset, and use the original tracker count
+                    const patchParams = toPatch(dataset.activity);
+                    patchParams.totalTrackersBlocked = oldCount;
+
+                    // simulate the native side pushing the fresh data back into the page.
+                    setTimeout(() => {
+                        const cb = subs.get('activity_onDataPatch');
+                        cb(patchParams);
+                    }, 0);
+                    break;
+                }
                 default: {
                     console.warn('unhandled notification', msg);
                 }
@@ -53,6 +71,9 @@ export function activityMockTransport() {
             if (sub === 'activity_onDataUpdate') {
                 subs.set('activity_onDataUpdate', cb);
             }
+            if (sub === 'activity_onDataPatch') {
+                subs.set('activity_onDataPatch', cb);
+            }
             if (sub === 'activity_onDataUpdate' && url.searchParams.has('flood')) {
                 let count = 0;
                 const int = setInterval(() => {
diff --git a/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js b/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js
index ecf187a7a3c..b66d2dad3ba 100644
--- a/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js
+++ b/special-pages/pages/new-tab/app/activity/mocks/activity.mocks.js
@@ -40,6 +40,23 @@ export const activityMocks = {
             },
         ],
     },
+    singleWithTrackers: {
+        activity: [
+            {
+                favicon: { src: 'selco-icon.png' },
+                url: 'https://example.com',
+                title: 'example.com',
+                etldPlusOne: 'example.com',
+                favorite: false,
+                trackersFound: true,
+                trackingStatus: {
+                    trackerCompanies: [{ displayName: 'Google' }, { displayName: 'Facebook' }, { displayName: 'Amazon' }],
+                    totalCount: 56,
+                },
+                history: [],
+            },
+        ],
+    },
     few: {
         activity: [
             {
diff --git a/special-pages/pages/new-tab/app/privacy-stats/components/ActivityHeading.js b/special-pages/pages/new-tab/app/privacy-stats/components/ActivityHeading.js
index b3f247a79ec..3913830133d 100644
--- a/special-pages/pages/new-tab/app/privacy-stats/components/ActivityHeading.js
+++ b/special-pages/pages/new-tab/app/privacy-stats/components/ActivityHeading.js
@@ -24,8 +24,8 @@ export function ActivityHeading({ expansion, canExpand, itemCount, trackerCount,
     const [formatter] = useState(() => new Intl.NumberFormat());
     const adBlocking = useAdBlocking();
 
-    const none = itemCount === 0;
-    const someItems = itemCount > 0;
+    const none = itemCount === 0 && trackerCount === 0;
+    const some = itemCount > 0 || trackerCount > 0;
     const trackerCountFormatted = formatter.format(trackerCount);
 
     let allTimeString;
@@ -46,7 +46,7 @@ export function ActivityHeading({ expansion, canExpand, itemCount, trackerCount,
                 <img src={adBlocking ? './icons/shield-green.svg' : './icons/shield.svg'} alt="Privacy Shield" />
             </span>
             {none && <h2 className={styles.title}>{t('activity_noRecent_title')}</h2>}
-            {someItems && (
+            {some && (
                 <h2 className={styles.title}>
                     <Trans str={allTimeString} values={{ count: trackerCountFormatted }} />
                 </h2>
@@ -64,12 +64,12 @@ export function ActivityHeading({ expansion, canExpand, itemCount, trackerCount,
                     />
                 </span>
             )}
-            {itemCount === 0 && (
+            {none && (
                 <p className={cn(styles.subtitle, { [styles.indented]: !adBlocking })}>
                     {adBlocking ? t('activity_noRecentAdsAndTrackers_subtitle') : t('activity_noRecent_subtitle')}
                 </p>
             )}
-            {itemCount > 0 && (
+            {some && (
                 <p className={cn(styles.subtitle, styles.indented, { [styles.uppercase]: !adBlocking })}>
                     {t('stats_feedCountBlockedPeriod')}
                 </p>


Reviews:
user: login: shakyShane
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T19:38:55Z
user: login: shakyShane
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T19:46:39Z
user: login: q71114
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T08:36:55Z

Review comments:
diff_hunk: @@ -131,7 +146,7 @@ export class BatchingPage {
         });
 
         // now remove the first item via subscription
-        await this.removesItem(0);
+        await this.itemRemovedViaPatch(0);
path: special-pages/pages/new-tab/app/activity/integration-tests/batching.page.js
user: login: shakyShane
body: renamed, because it’s not obvious that this is not driven by a user-action
created_at: 2025-05-10T19:38:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,6 +36,24 @@ export function activityMockTransport() {
             /** @type {import('../../../types/new-tab.ts').NewTabMessages['notifications']} */
             const msg = /** @type {any} */ (_msg);
             switch (msg.method) {
+                case 'activity_removeItem': {
+                    // grab the tracker count of the current dataset before we alter it
+                    const oldCount = dataset.activity.reduce((acc, item) => acc + item.trackingStatus.totalCount, 0);
+
+                    // now filter the items
+                    dataset.activity = dataset.activity.filter((x) => x.url !== msg.params.url);
+
+                    // create the patch dataset, and use the original tracker count
+                    const patchParams = toPatch(dataset.activity);
+                    patchParams.totalTrackersBlocked = oldCount;
+
+                    // simulate the native side pushing the fresh data back into the page.
+                    setTimeout(() => {
+                        const cb = subs.get('activity_onDataPatch');
+                        cb(patchParams);
+                    }, 0);
+                    break;
+                }
path: special-pages/pages/new-tab/app/activity/mocks/activity.mock-transport.js
user: login: shakyShane
body: This scenario was missing from the mocks.
created_at: 2025-05-10T19:46:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-05-10T19:20:10Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *content-scope-scripts* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 3aeee46d6cffbbf72d6a02c798095bddf13e4032 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/content-scope-scripts/deploys/681fac7603e5790008928ae8 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-1677--content-scope-scripts.netlify.app](https://deploy-preview-1677--content-scope-scripts.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTE2NzctLWNvbnRlbnQtc2NvcGUtc2NyaXB0cy5uZXRsaWZ5LmFwcCJ9.KK9FJ5HCFPtng-djSnG0VVV4rcbkf4SBf8xi5WCPaf0)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [content-scope-scripts Preview](https://deploy-preview-1677--content-scope-scripts.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/content-scope-scripts/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: shakyShane
created_at: 2025-05-10T19:20:24Z
body: * **#1677** <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1677?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1677?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-10T19:20:33Z
body: ### Temporary Branch Update

The temporary branch has been updated with the latest changes. Below are the details:

  - **Branch Name**: [pr-releases/pr-1677](https://github.com/duckduckgo/content-scope-scripts/tree/pr-releases/pr-1677)
  - **Commit Hash**: [39348daaef415cd1959a9cd7fc472da6fa740f30](https://github.com/duckduckgo/content-scope-scripts/commit/39348daaef415cd1959a9cd7fc472da6fa740f30)
  - **Last Updated**: May 10, 2025 at 7:44:15 PM UTC
  - **Install Command**: `npm i github:duckduckgo/content-scope-scripts#39348daaef415cd1959a9cd7fc472da6fa740f30`

  Please use the above install command to update to the latest version.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-10T19:20:54Z
body: ### *[Beta]* Generated file diff
*Time updated:* Sat, 10 May 2025 19:44:27 GMT

<details >
<summary>Integration</summary>

        - integration/pages/new-tab/dist/index.js


File has changed
</details>
<details open>
<summary>Windows</summary>

        - windows/pages/new-tab/dist/index.js


File has changed
</details>
<details open>
<summary>Apple</summary>

        - dist/pages/new-tab/dist/index.js


File has changed
</details>
author_association: CONTRIBUTOR
user: login: shakyShane
created_at: 2025-05-12T08:37:42Z
body: @q71114  tested this in CI with 20 runs of the failing/flaky test, and confirmed it’s now at 100%
author_association: CONTRIBUTOR