Skip to content

feat(preferred-sources): ask readers to make daily.dev preferred on Google - #6667

Merged
idoshamun merged 5 commits into
mainfrom
preferred-source-google
Sep 16, 2026
Merged

idoshamun merged 5 commits into
mainfrom
preferred-source-google

Conversation

@idoshamun

@idoshamun idoshamun commented Sep 16, 2026

Copy link
Copy Markdown
Member

Brings the post page and settings surfaces from the design branch (#6619) into production shape. The feed ad fallback and the Storybook mock-ups from that branch are not here.

Changes

Google lets a reader mark a site as preferred, after which it surfaces that site more often in Top Stories, AI Overviews and AI Mode. Two surfaces ask for it:

  • Post pages — in the widget rail, directly above the share bar. Placement follows the intent: a reader weighing "would you recommend this post?" has already decided the source was worth their time. It renders from all three widget columns — PostWidgets (article, /read), SquadPostWidgets (squad, social, poll) and CollectionPostWidgets — so every post type that has a rail gets it. Brief and digest posts do not: they render no rail at all, only PostContainer. On the squad rail it sits outside the canShare gate, since a private squad can't share a post but its readers can still want daily.dev in their Google results. Goes quiet once the reader answers.
  • Settings → Appearance — a permanent row. Every other surface silences itself on the first click, so without this a reader who changed their mind has no way back in.

Worth knowing about the mechanism: Google's JS API takes no arguments and always adds the domain hosting the script. On a post page that is daily.dev, never the article's publisher, so this can only ever ask on our own behalf. It runs in manual mode because the documented auto-scan happens once at script load, and every post page here is reached by a client-side route change long after that — the auto-scan would find nothing.

The script is fetched on demand rather than from <head>, so a page that never shows the ask never pays for it. If it never arrives (the news.google.com/swg/... host is exactly what a content blocker eats, and a blocked script fires no error in every browser), a 4s timeout drops the button to Google's deeplink, which needs no script.

The "already added" state lives in idb-keyval, so it is per browser rather than per account, and it is written optimistically — abandoning Google's dialog still counts as done. Neither is fixable: Google exposes no read API.

Differences from #6619

  • No feature flag. The ask ships to everyone, so there is no gate and no rollback short of a deploy.
  • The ?preferredSource=1 review backdoor is gone — it was marked must-remove-before-merge on that branch.
  • The settings row goes through the same hook as the post rail. On the design branch it called the script hook directly, which would have rendered the row and pulled in Google's script for everyone on Appearance regardless of the flag.
  • Dead code dropped: normalizePreferredSourceDomain (no caller once the per-publisher deeplink went), the dismissed state and its event (no surface left to fire them), and the widgetsLeading prop chain through PostContent/common.tsx (only ever fed a Storybook story).

Events

Type event_name value
New impression preferred source target_type: preferred source, target_id: <placement>
New click preferred source target_type: preferred source, target_id: <placement>, extra: { deeplink: boolean }
New preferred source blocked target_type: preferred source, target_id: <placement>

<placement> is one of post widgets, squad post widgets, collection post widgets or settings — each rail passes its own, since the component cannot tell which one mounted it.

Google reports nothing back — no read API, no Search Console dimension — so nothing downstream of the click is observable and impression → click is the only conversion rate this feature can ever have.

Two things the events deliberately separate:

  • deeplink on the click. Google's dialog is a same-tab overlay and the deeplink is a new tab, so they are not the same ask. The split is the only evidence for whether the fallback earns its place.
  • preferred source blocked as its own event. The impression cannot carry it: the impression fires on sight, the block only resolves on the timeout after it. Without it, readers whose blocker ate the script are indistinguishable from readers who saw the ask and refused — they were shown a button that could never be clicked.

Analytics Taxonomy sheet still needs updating.

Note

Impressions fire on mount, not on viewport entry. The rail is laptop:flex-row, so below 1020px it stacks after the main column and its comments — the button is far below the fold on mobile and tablet, but still logs an impression. Mobile conversion will read as artificially low. Moving to react-intersection-observer (as useLogImpression already does for feed items) would fix it; not done here.

Experiment

No.

Manual Testing

Not verified in a browser. The visual design is unchanged from #6619, which has its own preview deploy; what changed here is placement, gating, structure and analytics.

  • usePreferredSource.spec.tsx — 9 tests: the cap silences the rail after an add and leaves the settings row alone, and the event payloads are asserted directly (impression once and only for a shown ask, click carries the right deeplink both ways, blocked fires only on failure).
  • Shared suite 426 files / 3112 tests, webapp suite 90 files / 717 tests, both green.
  • Strict typecheck on changed files, full webapp tsc (no errors in touched files), ESLint and Prettier clean.

Important

Google's script appends an undocumented 1×1 iframe to <body>. #6619 wanted privacy sign-off on that before the flag went above 0%; with no flag here, that sign-off has to happen before merge.

Preview domain

https://preferred-source-google.preview.app.daily.dev

@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Sep 16, 2026 9:35am UTC

Request Review

…oogle

Ports the post page and settings surfaces from the design branch (#6619),
leaving the feed ad fallback and the Storybook mock-ups behind.

Google's JS API always adds the domain hosting the script, so on a post page
the ask is for daily.dev and never for the article's publisher. It runs in
manual mode because the documented auto-scan happens once at script load and
every post page here is reached by a client-side route change long after that.

The settings row now goes through the same gate as the post widget rather than
calling the script hook directly. Without that it rendered — and pulled in
Google's script — for everyone on Appearance regardless of the flag. It passes
isPermanent so it stays visible after the reader has answered, which is the
whole reason it exists: there is no read API, so "added" is our own optimistic
state and a reader who changed their mind needs a way back in.

Dropped on the way over: the ?preferredSource=1 review backdoor (GrowthBook dev
mode already overrides flags outside production), the dismissed state and its
event along with the only surface that could fire them, and
normalizePreferredSourceDomain, which had no caller once the per-publisher
deeplink went.
The ask ships to everyone, so the GrowthBook gate goes with it. That also
removes the only reason this hook read auth state: flag evaluation waited on
isAuthReady, while nothing else here does. Events fired before boot are held by
useBackfillPendingLogs and sent once shared props land, so the impression is
still attributed.

Measurement has to carry the whole feature: Google has no read API and no
Search Console dimension, so nothing downstream of the click is observable and
the events are all we get. Two gaps closed:

- The click now records which route it took. Google's dialog and the deeplink
  are a same-tab overlay and a new tab, so they are not the same ask, and the
  split is the only thing that says whether the fallback earns its place.
- A blocked script gets its own event. The impression cannot carry it — the
  impression fires on sight, the block resolves on a timeout after it — so
  without one those readers look like an audience that saw the ask and refused,
  when they were shown a button that could never be clicked.
…ost type

Placement follows the intent rather than the page order: a reader weighing
"would you recommend this post?" has already decided the source was worth their
time, which is a better moment to ask than the top of a rail they have not read
yet.

The ask now renders from all three widget columns instead of only the article
one, so squad, social, poll and collection posts get it too. Brief and digest
posts still do not: they render no rail at all, only PostContainer, and giving
them one is a layout change this does not want to make.

On the squad rail it sits outside the canShare gate. A private squad cannot
share a post, but that says nothing about whether the reader wants daily.dev in
their Google results.

Each rail passes its own placement, since the component cannot tell which one
mounted it and the post types are worth separating in the events.
Google tells the backend nothing, so without this call the API never learns the
reader clicked and the achievement could never fire. The mutation records the
attempt, not the outcome.

Fire and forget, and skipped when signed out. The ask deliberately renders for
signed-out readers, but the mutation is `@auth` and there is no user to credit,
so calling it would only produce a guaranteed error. A failure must not stop
Google's dialog opening or the prompt going quiet either: the achievement is
the least important thing happening on this click.
@idoshamun

Copy link
Copy Markdown
Member Author

Backend half is up: dailydotdev/daily-api#4275 — adds AchievementEventType.PreferredSourceAdd, the addPreferredSource mutation this branch now calls, and the migration seeding the Teacher's pet achievement. The two need to land together: without the API change the mutation 404s, and without this branch nothing ever calls it.

The article page view test asserted the total number of log events, so the
preferred source impression firing from the widget rail made it two and broke
the test. It only passed locally because the ask is gated on an idb read that
usually settles after the assertion, which made the failure a race rather than
a certainty.

Counting the page view itself keeps what the test is actually guarding, that
the view is not logged twice, and stops the next widget with an impression from
breaking it again.
@idoshamun
idoshamun merged commit d1262ee into main Sep 16, 2026
11 checks passed
@idoshamun
idoshamun deleted the preferred-source-google branch September 16, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant