From 49e8359c43f14ca79433a5cfba3ec12f63158572 Mon Sep 17 00:00:00 2001
From: Chris Bongers
Date: Fri, 18 Sep 2026 11:44:41 +0200
Subject: [PATCH 1/2] feat(post): add topic-based signup experiment
---
.../src/components/auth/SignupWidget.tsx | 5 +-
.../src/components/post/BasePostContent.tsx | 2 +
.../components/post/PostSignupWidget.spec.tsx | 197 ++++++++++++++++++
.../src/components/post/PostSignupWidget.tsx | 45 +++-
.../src/components/post/PostTopicSignup.tsx | 110 ++++++++++
.../src/components/post/PostWidgets.tsx | 2 +-
.../src/components/post/SquadPostWidgets.tsx | 2 +-
.../post/collection/CollectionPostWidgets.tsx | 2 +-
.../components/post/focus/PostFocusCard.tsx | 2 +
.../shared/src/graphql/postTopicSignup.ts | 45 ++++
packages/shared/src/lib/featureManagement.ts | 1 +
packages/shared/src/lib/log.ts | 1 +
12 files changed, 406 insertions(+), 8 deletions(-)
create mode 100644 packages/shared/src/components/post/PostSignupWidget.spec.tsx
create mode 100644 packages/shared/src/components/post/PostTopicSignup.tsx
create mode 100644 packages/shared/src/graphql/postTopicSignup.ts
diff --git a/packages/shared/src/components/auth/SignupWidget.tsx b/packages/shared/src/components/auth/SignupWidget.tsx
index 814899db099..e8b5826dbb1 100644
--- a/packages/shared/src/components/auth/SignupWidget.tsx
+++ b/packages/shared/src/components/auth/SignupWidget.tsx
@@ -1,4 +1,4 @@
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';
import { useAuthContext } from '../../contexts/AuthContext';
@@ -33,6 +33,7 @@ const denseContainer =
interface SignupWidgetProps {
title: string;
description: string;
+ children?: ReactNode;
/** Which surface is asking, for the analytics on the resulting signup. */
trigger: AuthTriggersType;
/**
@@ -58,6 +59,7 @@ export function SignupWidget({
trigger,
dense,
className,
+ children,
}: SignupWidgetProps): ReactElement {
const { showLogin } = useAuthContext();
@@ -93,6 +95,7 @@ export function SignupWidget({
>
{description}
+ {children}
import(/* webpackChunkName: "custom404" */ '../Custom404'),
@@ -65,6 +66,7 @@ export function BasePostContent({
)}
{children}
+ {!!post?.id && }
{isPostPage && }
{aboveComments}
{!!engagementProps && (
diff --git a/packages/shared/src/components/post/PostSignupWidget.spec.tsx b/packages/shared/src/components/post/PostSignupWidget.spec.tsx
new file mode 100644
index 00000000000..30022690b94
--- /dev/null
+++ b/packages/shared/src/components/post/PostSignupWidget.spec.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient } from '@tanstack/react-query';
+import { GrowthBook } from '@growthbook/growthbook-react';
+import { useInView } from 'react-intersection-observer';
+import nock from 'nock';
+import { TestBootProvider } from '../../../__tests__/helpers/boot';
+import { mockGraphQL } from '../../../__tests__/helpers/graphql';
+import post from '../../../__tests__/fixture/post';
+import loggedUser from '../../../__tests__/fixture/loggedUser';
+import type { AuthContextData } from '../../contexts/AuthContext';
+import { TAG_TITLES_QUERY } from '../../graphql/keywords';
+import { POST_TOPIC_SIGNUP_QUERY } from '../../graphql/postTopicSignup';
+import { AuthTriggers } from '../../lib/auth';
+import { AuthDisplay } from '../auth/common';
+import { PostSignupWidget } from './PostSignupWidget';
+
+jest.mock('react-intersection-observer', () => ({
+ ...jest.requireActual('react-intersection-observer'),
+ useInView: jest.fn(),
+}));
+
+jest.mock('../auth/AuthOptions', () => ({
+ __esModule: true,
+ default: ({
+ onAuthStateUpdate,
+ }: {
+ onAuthStateUpdate: (props: { defaultDisplay: string }) => void;
+ }) => {
+ const { AuthDisplay: Display } = jest.requireActual('../auth/common');
+ return (
+
+ );
+ },
+}));
+
+const mockUseInView = useInView as jest.Mock;
+const showLogin = jest.fn();
+const examplePost = {
+ ...post,
+ id: 'related-post',
+ title: 'A practical web development guide',
+ commentsPermalink: '/posts/related-post',
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ nock.cleanAll();
+ mockUseInView.mockReturnValue([jest.fn(), true]);
+});
+
+const renderWidget = ({
+ topicEnabled = true,
+ genericEnabled = true,
+ inline = true,
+ currentPost = post,
+ auth = {},
+}: {
+ topicEnabled?: boolean;
+ genericEnabled?: boolean;
+ inline?: boolean;
+ currentPost?: typeof post;
+ auth?: Partial;
+} = {}) => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const gb = new GrowthBook({
+ features: {
+ post_topic_signup: { defaultValue: topicEnabled },
+ post_signup_widget: { defaultValue: genericEnabled },
+ },
+ });
+
+ return render(
+
+
+ ,
+ );
+};
+
+const mockPreviews = (currentPost = post, fail = false) => {
+ mockGraphQL({
+ request: { query: TAG_TITLES_QUERY },
+ result: {
+ data: {
+ tags: [{ value: 'webdev', flags: { title: 'Web Development' } }],
+ },
+ },
+ });
+ mockGraphQL({
+ request: {
+ query: POST_TOPIC_SIGNUP_QUERY,
+ variables: { post: currentPost.id, tag: 'webdev', first: 2 },
+ },
+ result: fail
+ ? { errors: [{ message: 'Preview unavailable' }] }
+ : { data: { similarPosts: [examplePost] } },
+ });
+};
+
+it('keeps the existing sidebar offer when the topic experiment is off', () => {
+ renderWidget({ topicEnabled: false, inline: false });
+ expect(screen.getByText('Want your personalized dev feed?')).toBeVisible();
+ expect(mockUseInView).not.toHaveBeenCalled();
+});
+
+it('does not add an inline offer to the control', () => {
+ renderWidget({ topicEnabled: false });
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ expect(mockUseInView).not.toHaveBeenCalled();
+});
+
+it.each([{ user: loggedUser }, { isAuthReady: false }])(
+ 'does not show or fetch an offer for an ineligible visitor: %j',
+ (auth) => {
+ renderWidget({ auth });
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ expect(mockUseInView).not.toHaveBeenCalled();
+ },
+);
+
+it('avoids a second signup card in the sidebar during the experiment', () => {
+ renderWidget({ inline: false });
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
+ expect(mockUseInView).not.toHaveBeenCalled();
+});
+
+it('falls back to the existing offer for posts without topics', () => {
+ renderWidget({ inline: false, currentPost: { ...post, tags: [] } });
+ expect(screen.getByText('Want your personalized dev feed?')).toBeVisible();
+});
+
+it('uses the raw topic before it is visible without fetching previews', () => {
+ mockUseInView.mockReturnValue([jest.fn(), false]);
+ mockPreviews();
+ renderWidget();
+ expect(screen.getByText('Get more posts about #webdev')).toBeVisible();
+ expect(nock.pendingMocks()).toHaveLength(2);
+});
+
+it('shows backend topic titles and public previews while preserving signup', async () => {
+ mockPreviews();
+ renderWidget();
+ expect(
+ await screen.findByText('Get more posts about Web Development'),
+ ).toBeVisible();
+ expect(
+ await screen.findByRole('link', { name: /practical web/ }),
+ ).toHaveAttribute('href', examplePost.commentsPermalink);
+ fireEvent.click(screen.getByRole('button', { name: 'Continue with email' }));
+ expect(showLogin).toHaveBeenCalledWith({
+ trigger: AuthTriggers.PostPage,
+ options: {
+ isLogin: false,
+ defaultDisplay: AuthDisplay.Registration,
+ formValues: undefined,
+ },
+ });
+});
+
+it('uses the underlying article topic on shared posts', async () => {
+ mockPreviews();
+ renderWidget({
+ currentPost: {
+ ...post,
+ id: 'share',
+ tags: [],
+ sharedPost: { ...post, title: 'Shared article', image: '/article.png' },
+ },
+ });
+ expect(
+ await screen.findByRole('link', { name: /practical web/ }),
+ ).toBeVisible();
+ await waitFor(() => expect(nock.isDone()).toBe(true));
+});
+
+it('keeps signup usable when recommendations fail', async () => {
+ mockPreviews(post, true);
+ renderWidget();
+ await waitFor(() => expect(nock.isDone()).toBe(true));
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Continue with email' }),
+ ).toBeEnabled();
+});
diff --git a/packages/shared/src/components/post/PostSignupWidget.tsx b/packages/shared/src/components/post/PostSignupWidget.tsx
index cdb797f6b68..b3a3e8f99fe 100644
--- a/packages/shared/src/components/post/PostSignupWidget.tsx
+++ b/packages/shared/src/components/post/PostSignupWidget.tsx
@@ -2,19 +2,55 @@ import type { ReactElement } from 'react';
import React from 'react';
import { useAuthContext } from '../../contexts/AuthContext';
import { useConditionalFeature } from '../../hooks/useConditionalFeature';
-import { featurePostSignupWidget } from '../../lib/featureManagement';
+import {
+ featurePostSignupWidget,
+ featurePostTopicSignup,
+} from '../../lib/featureManagement';
import { AuthTriggers } from '../../lib/auth';
import { SignupWidget } from '../auth/SignupWidget';
+import type { Post } from '../../graphql/posts';
+import { PostTopicSignup } from './PostTopicSignup';
-export function PostSignupWidget(): ReactElement | null {
+interface PostSignupWidgetProps {
+ post: Post;
+ inline?: boolean;
+ className?: string;
+}
+
+export function PostSignupWidget({
+ post,
+ inline = false,
+ className,
+}: PostSignupWidgetProps): ReactElement | null {
const { user, isAuthReady } = useAuthContext();
+ const article = post.sharedPost ?? post;
+ const tag = article.tags?.find((value) => value.trim().length > 0);
const shouldEvaluate = isAuthReady && !user;
+ const { value: isTopicEnabled } = useConditionalFeature({
+ feature: featurePostTopicSignup,
+ shouldEvaluate: shouldEvaluate && !!tag,
+ });
const { value: isEnabled } = useConditionalFeature({
feature: featurePostSignupWidget,
- shouldEvaluate,
+ shouldEvaluate: shouldEvaluate && !inline && !isTopicEnabled,
});
- if (!shouldEvaluate || !isEnabled) {
+ if (!shouldEvaluate) {
+ return null;
+ }
+
+ if (isTopicEnabled && tag) {
+ return inline ? (
+
+ ) : null;
+ }
+
+ if (inline || !isEnabled) {
return null;
}
@@ -23,6 +59,7 @@ export function PostSignupWidget(): ReactElement | null {
title="Want your personalized dev feed?"
description="Millions of developers rely on daily.dev for tech news, tools, and discussions that actually matter."
trigger={AuthTriggers.PostPage}
+ className={className}
/>
);
}
diff --git a/packages/shared/src/components/post/PostTopicSignup.tsx b/packages/shared/src/components/post/PostTopicSignup.tsx
new file mode 100644
index 00000000000..6625f25d3c8
--- /dev/null
+++ b/packages/shared/src/components/post/PostTopicSignup.tsx
@@ -0,0 +1,110 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import type { Post } from '../../graphql/posts';
+import {
+ POST_TOPIC_SIGNUP_PREVIEW_COUNT,
+ postTopicSignupQueryOptions,
+} from '../../graphql/postTopicSignup';
+import { tagTitlesQueryOptions } from '../../graphql/keywords';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useLogContext } from '../../contexts/LogContext';
+import useLogEventOnce from '../../hooks/log/useLogEventOnce';
+import { AuthTriggers } from '../../lib/auth';
+import { LogEvent, TargetType } from '../../lib/log';
+import { SignupWidget } from '../auth/SignupWidget';
+import Link from '../utilities/Link';
+
+interface PostTopicSignupProps {
+ post: Post;
+ tag: string;
+ className?: string;
+}
+
+export function PostTopicSignup({
+ post,
+ tag,
+ className,
+}: PostTopicSignupProps): ReactElement {
+ const { tokenRefreshed } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const [ref, inView] = useInView({ triggerOnce: true });
+ const enabled = tokenRefreshed && inView;
+ const { data: titles } = useQuery({
+ ...tagTitlesQueryOptions(),
+ enabled,
+ });
+ const { data } = useQuery({
+ ...postTopicSignupQueryOptions(post.id, tag),
+ enabled,
+ });
+ const topic = titles?.[tag] || `#${tag}`;
+ const previews = (data?.similarPosts ?? [])
+ .filter(
+ (preview, index, posts) =>
+ preview.id !== post.id &&
+ posts.findIndex(({ id }) => id === preview.id) === index,
+ )
+ .slice(0, POST_TOPIC_SIGNUP_PREVIEW_COUNT);
+ const extra = JSON.stringify({ origin: 'post topic signup', tag });
+
+ useLogEventOnce(
+ () => ({
+ event_name: LogEvent.Impression,
+ target_type: TargetType.PostTopicSignup,
+ target_id: post.id,
+ extra,
+ }),
+ { condition: inView },
+ );
+
+ return (
+
+
+ {previews.length > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx
index c63abe71173..5f7908ecded 100644
--- a/packages/shared/src/components/post/PostWidgets.tsx
+++ b/packages/shared/src/components/post/PostWidgets.tsx
@@ -164,7 +164,7 @@ export function PostWidgets({
return (
- {!hideSignupWidget && }
+ {!hideSignupWidget && }
{withAd(PostWidgetPosition.Source, sourceCard)}
{withAd(
PostWidgetPosition.Creator,
diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx
index a90eb5f1088..875fa223fca 100644
--- a/packages/shared/src/components/post/SquadPostWidgets.tsx
+++ b/packages/shared/src/components/post/SquadPostWidgets.tsx
@@ -37,7 +37,7 @@ export function SquadPostWidgets({
return (
-
+
{!isUserSource &&
(isSquadSource ? (
{
return (
-
+
+
{showCommunitySentiment && (
diff --git a/packages/shared/src/graphql/postTopicSignup.ts b/packages/shared/src/graphql/postTopicSignup.ts
new file mode 100644
index 00000000000..e12e451ccb6
--- /dev/null
+++ b/packages/shared/src/graphql/postTopicSignup.ts
@@ -0,0 +1,45 @@
+import { gql } from 'graphql-request';
+import type { Post } from './posts';
+import { gqlBatchRequest } from './batch';
+import { StaleTime } from '../lib/query';
+
+export const POST_TOPIC_SIGNUP_PREVIEW_COUNT = 2;
+
+export type PostTopicSignupPreview = Pick<
+ Post,
+ 'id' | 'title' | 'commentsPermalink'
+> & {
+ source?: { name: string } | null;
+};
+
+export interface PostTopicSignupData {
+ similarPosts: PostTopicSignupPreview[];
+}
+
+export const POST_TOPIC_SIGNUP_QUERY = gql`
+ query PostTopicSignup($post: ID!, $tag: String!, $first: Int!) {
+ similarPosts: randomSimilarPostsByTags(
+ post: $post
+ tags: [$tag]
+ first: $first
+ ) {
+ id
+ title
+ commentsPermalink
+ source {
+ name
+ }
+ }
+ }
+`;
+
+export const postTopicSignupQueryOptions = (postId: string, tag: string) => ({
+ queryKey: ['postTopicSignup', postId, tag],
+ queryFn: () =>
+ gqlBatchRequest(POST_TOPIC_SIGNUP_QUERY, {
+ post: postId,
+ tag,
+ first: POST_TOPIC_SIGNUP_PREVIEW_COUNT,
+ }),
+ staleTime: StaleTime.OneHour,
+});
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 813e0a0615f..0cce685d978 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -186,6 +186,7 @@ export const featureOnboardingPersonas = new Feature(
);
export const featurePostSignupWidget = new Feature('post_signup_widget', false);
+export const featurePostTopicSignup = new Feature('post_topic_signup', false);
export const featureShortcutsHub = new Feature('shortcuts_hub_v2', false);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index a9c2e4fd7bd..b87f8040d48 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -643,6 +643,7 @@ export enum TargetType {
AdvertiseHereCta = 'advertise here cta',
ExtensionPromo = 'extension promo',
ProfileWorldToggle = 'profile world toggle',
+ PostTopicSignup = 'post topic signup',
}
export enum TargetId {
From ccfda3bb8af5675295552c763587de59ab2a689a Mon Sep 17 00:00:00 2001
From: Chris Bongers
Date: Fri, 18 Sep 2026 12:52:20 +0200
Subject: [PATCH 2/2] style(post): center and refine topic signup offer
---
.../src/components/auth/SignupWidget.tsx | 34 ++++++++++++++++---
.../src/components/post/PostTopicSignup.tsx | 11 ++++--
2 files changed, 37 insertions(+), 8 deletions(-)
diff --git a/packages/shared/src/components/auth/SignupWidget.tsx b/packages/shared/src/components/auth/SignupWidget.tsx
index e8b5826dbb1..222212011b0 100644
--- a/packages/shared/src/components/auth/SignupWidget.tsx
+++ b/packages/shared/src/components/auth/SignupWidget.tsx
@@ -42,6 +42,7 @@ interface SignupWidgetProps {
* ranking without putting the rail into a scroll. The legal strip stays.
*/
dense?: boolean;
+ centered?: boolean;
className?: string;
}
@@ -58,6 +59,7 @@ export function SignupWidget({
description,
trigger,
dense,
+ centered = false,
className,
children,
}: SignupWidgetProps): ReactElement {
@@ -67,10 +69,19 @@ export function SignupWidget({