From 23c364591d31a75c7ce335852806e837192034bb Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 7 Aug 2026 15:36:57 +0200 Subject: [PATCH 1/2] feat: layout v2 ssr --- packages/shared/src/components/MainLayout.tsx | 32 ++++++--- packages/shared/src/lib/feature.ts | 12 ++++ packages/shared/src/lib/featureManagement.ts | 16 +---- packages/shared/src/lib/serverFeatureValue.ts | 39 +++++++++++ packages/shared/src/lib/serverFeatures.ts | 5 ++ .../webapp/__tests__/MainLayoutPaintHold.tsx | 31 +++++++- .../__tests__/layoutVariantMiddleware.ts | 70 +++++++++++++++++++ packages/webapp/__tests__/middleware.ts | 46 ++++++++++++ .../webapp/lib/layoutVariantMiddleware.ts | 42 +++++++++++ packages/webapp/middleware.ts | 22 ++++-- packages/webapp/next.config.ts | 6 ++ .../webapp/pages/layout-v2/posts/[id].tsx | 23 ++++++ packages/webapp/pages/posts/[id]/index.tsx | 11 ++- 13 files changed, 321 insertions(+), 34 deletions(-) create mode 100644 packages/shared/src/lib/feature.ts create mode 100644 packages/shared/src/lib/serverFeatureValue.ts create mode 100644 packages/shared/src/lib/serverFeatures.ts create mode 100644 packages/webapp/__tests__/layoutVariantMiddleware.ts create mode 100644 packages/webapp/__tests__/middleware.ts create mode 100644 packages/webapp/lib/layoutVariantMiddleware.ts create mode 100644 packages/webapp/pages/layout-v2/posts/[id].tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 4a30e735dc6..d49bd1dc4d6 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -76,6 +76,8 @@ export interface MainLayoutProps canGoBack?: string; hideBackButton?: boolean; hideFeedbackWidget?: boolean; + /** Uses the server-selected layout shell before client boot resolves. */ + layoutVariant?: 'v1' | 'v2'; /** * Layout v2 only. Rendered above the floating feed card, alongside the * built-in reading-reminder TopHero. Pages can pass dynamic banners @@ -102,6 +104,7 @@ function MainLayoutComponent({ canGoBack, hideFeedbackWidget = false, topBanner, + layoutVariant, }: MainLayoutProps): ReactElement | null { const router = useRouter(); const { logEvent } = useLogContext(); @@ -130,7 +133,14 @@ function MainLayoutComponent({ const isLaptopXL = useViewSize(ViewSize.LaptopXL); const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); - const { isV2, isLoading: isLayoutVariantLoading } = useLayoutVariant(); + const { isV2: evaluatedIsV2, isLoading: evaluatedLayoutVariantLoading } = + useLayoutVariant(); + const isLayoutVariantForced = layoutVariant !== undefined; + const isForcedV2 = layoutVariant === 'v2'; + const isV2 = isForcedV2 || (!isLayoutVariantForced && evaluatedIsV2); + const isLayoutVariantLoading = isLayoutVariantForced + ? false + : evaluatedLayoutVariantLoading; useRecordRecentPages(isV2); useNotificationParams(); useFeedbackShortcut(); @@ -206,7 +216,8 @@ function MainLayoutComponent({ // `isLaptop` alone made the server emit one and the client skip it, which // shifted `
` and broke hydration. const isLayoutChromeResolved = - !isHoldingChrome && (!isLaptop || !isLayoutVariantLoading); + isLayoutVariantForced || + (!isHoldingChrome && (!isLaptop || !isLayoutVariantLoading)); // Extension new tab mounts its own `ExtensionTopBanners` strip, so // the webapp strip is suppressed there to avoid duplicate cards. @@ -221,7 +232,11 @@ function MainLayoutComponent({ // floating-card treatment, and the global feedback widget is suppressed // because the rail provides its own. const sidebarOwnsHeader = - isV2 && (isLoggedIn || isExtension) && showSidebar && sidebarRendered; + isV2 && + (isForcedV2 || isLoggedIn || isExtension) && + showSidebar && + (isForcedV2 || sidebarRendered); + const shouldRenderHeader = !sidebarOwnsHeader && isLayoutChromeResolved; useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { @@ -334,13 +349,10 @@ function MainLayoutComponent({ /> )} - {/* Temporary while layout v2 is experimental: production users are on - v1, so render its header in the initial HTML instead of waiting for - feature resolution and delaying the post page's LCP. */} - {!sidebarOwnsHeader && ( + {shouldRenderHeader && ( @@ -350,7 +362,7 @@ function MainLayoutComponent({ 'flex flex-col', animateContentPadding && 'transition-[padding] duration-300 ease-in-out', - !sidebarOwnsHeader && 'laptop:pt-16', + shouldRenderHeader && 'laptop:pt-16', showSidebar && (isV2 ? v2CollapsedPadding : 'tablet:pl-16 laptop:pl-11'), className, @@ -358,7 +370,7 @@ function MainLayoutComponent({ showSidebar && (sidebarExpanded || forceSidebarExpanded) && (isV2 ? v2ExpandedPadding : !isScreenCentered && 'laptop:!pl-60'), - isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24', + isBannerAvailable && shouldRenderHeader && 'laptop:pt-24', )} > {isAuthReady && isLayoutChromeResolved && showSidebar && ( diff --git a/packages/shared/src/lib/feature.ts b/packages/shared/src/lib/feature.ts new file mode 100644 index 00000000000..67b14eb7c03 --- /dev/null +++ b/packages/shared/src/lib/feature.ts @@ -0,0 +1,12 @@ +import type { JSONValue } from '@growthbook/growthbook'; + +export class Feature { + readonly id: string; + + readonly defaultValue: T; + + constructor(id: string, defaultValue: T) { + this.id = id; + this.defaultValue = defaultValue; + } +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 8c57460e9b1..03c7e4a0ec7 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -1,4 +1,3 @@ -import type { JSONValue } from '@growthbook/growthbook'; import type { FeedAdTemplate } from './feed'; import type { FeedSettingsKeys } from '../contexts/FeedContext'; import type { PlusItemStatus } from '../components/plus/PlusListItem'; @@ -6,17 +5,10 @@ import { isDevelopment } from './constants'; import { BriefingType } from '../graphql/posts'; import type { HeroCardsConfig } from '../types'; import { PostType } from '../types'; +import { Feature } from './feature'; -export class Feature { - readonly id: string; - - readonly defaultValue: T; - - constructor(id: string, defaultValue: T) { - this.id = id; - this.defaultValue = defaultValue; - } -} +export { Feature } from './feature'; +export { featureLayoutV2 } from './serverFeatures'; const feature = { showError: new Feature('show_error', false), @@ -258,8 +250,6 @@ export const featureOnboardingChrome = new Feature( OnboardingChromeVariant.Control, ); -export const featureLayoutV2 = new Feature('layout_v2', false); - export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); export const featureHeroCards = new Feature('hero_cards', { diff --git a/packages/shared/src/lib/serverFeatureValue.ts b/packages/shared/src/lib/serverFeatureValue.ts new file mode 100644 index 00000000000..707ede46db8 --- /dev/null +++ b/packages/shared/src/lib/serverFeatureValue.ts @@ -0,0 +1,39 @@ +import { GrowthBook } from '@growthbook/growthbook'; +import type { JSONValue } from '@growthbook/growthbook'; +import type { Feature } from './feature'; + +const DEFAULT_API_HOST = 'https://cdn.growthbook.io'; +const DEFAULT_TIMEOUT = 2000; + +interface GetServerFeatureValueOptions { + attributes: Record; + clientKey?: string; + feature: Feature; +} + +export const getServerFeatureValue = async ({ + attributes, + clientKey, + feature, +}: GetServerFeatureValueOptions): Promise => { + const { defaultValue, id } = feature; + + if (!clientKey) { + return defaultValue; + } + + const growthbook = new GrowthBook({ + apiHost: process.env.GROWTHBOOK_API_HOST ?? DEFAULT_API_HOST, + attributes, + clientKey, + }); + + try { + await growthbook.loadFeatures({ timeout: DEFAULT_TIMEOUT }); + return growthbook.getFeatureValue(id, defaultValue) as T; + } catch { + return defaultValue; + } finally { + growthbook.destroy(); + } +}; diff --git a/packages/shared/src/lib/serverFeatures.ts b/packages/shared/src/lib/serverFeatures.ts new file mode 100644 index 00000000000..cc651e065c4 --- /dev/null +++ b/packages/shared/src/lib/serverFeatures.ts @@ -0,0 +1,5 @@ +import { Feature } from './feature'; + +// Features evaluated before the application bundle loads belong here so +// server entry points do not pull in featureManagement's client dependencies. +export const featureLayoutV2 = new Feature('layout_v2', false); diff --git a/packages/webapp/__tests__/MainLayoutPaintHold.tsx b/packages/webapp/__tests__/MainLayoutPaintHold.tsx index 445fb0dcc43..5a41fa517b6 100644 --- a/packages/webapp/__tests__/MainLayoutPaintHold.tsx +++ b/packages/webapp/__tests__/MainLayoutPaintHold.tsx @@ -46,13 +46,17 @@ describe('MainLayout before boot resolves', () => { .mockReturnValue({ isV2: false, isLoading: true }); }); - const renderLayout = (): RenderResult => + const renderLayout = ({ + layoutVariant: forcedVariant, + }: { + layoutVariant?: 'v1' | 'v2'; + } = {}): RenderResult => render( - +

prerendered page content

, @@ -73,11 +77,32 @@ describe('MainLayout before boot resolves', () => { expect(content.closest('div.antialiased')).not.toHaveClass('invisible'); }); - it('renders the header before the layout experiment resolves', () => { + it('does not reserve header space before the layout resolves', () => { mockRouter('/posts/[id]'); renderLayout(); + const content = screen.getByText('prerendered page content'); + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(content.closest('main')).not.toHaveClass('laptop:pt-16'); + }); + + it('renders the server-selected v1 header and its spacing', () => { + mockRouter('/posts/[id]'); + renderLayout({ layoutVariant: 'v1' }); + + const content = screen.getByText('prerendered page content'); expect(screen.getByRole('banner')).toBeInTheDocument(); + expect(content.closest('main')).toHaveClass('laptop:pt-16'); + }); + + it('renders the server-selected layout v2 shell before boot resolves', () => { + mockRouter('/layout-v2/posts/[id]'); + renderLayout({ layoutVariant: 'v2' }); + + const content = screen.getByText('prerendered page content'); + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(content.closest('main')).not.toHaveClass('laptop:pt-16'); + expect(content.closest('div.laptop\\:rounded-24')).toBeInTheDocument(); }); it('still renders nothing for feed-shaped pages', () => { diff --git a/packages/webapp/__tests__/layoutVariantMiddleware.ts b/packages/webapp/__tests__/layoutVariantMiddleware.ts new file mode 100644 index 00000000000..376f5720371 --- /dev/null +++ b/packages/webapp/__tests__/layoutVariantMiddleware.ts @@ -0,0 +1,70 @@ +/** @jest-environment node */ + +import { NextRequest } from 'next/server'; +import { getServerFeatureValue } from '@dailydotdev/shared/src/lib/serverFeatureValue'; +import { + isDesktopRequest, + resolveLayoutV2, +} from '../lib/layoutVariantMiddleware'; + +jest.mock('@dailydotdev/shared/src/lib/serverFeatureValue', () => ({ + getServerFeatureValue: jest.fn(), +})); + +const createRequest = ({ + cookie = 'da2=tracking-id; __Secure-dast=session', + mobile, + userAgent, +}: { + cookie?: string; + mobile?: string; + userAgent?: string; +} = {}): NextRequest => + new NextRequest('https://app.daily.dev/posts/test-post', { + headers: { + cookie, + ...(mobile && { 'sec-ch-ua-mobile': mobile }), + ...(userAgent && { 'user-agent': userAgent }), + }, + }); + +describe('layout variant middleware resolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getServerFeatureValue).mockResolvedValue(false); + }); + + it('uses the tracking id for the same allocation attributes as the client', async () => { + jest.mocked(getServerFeatureValue).mockResolvedValue(true); + + await expect(resolveLayoutV2(createRequest())).resolves.toBe(true); + expect(getServerFeatureValue).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + deviceId: 'tracking-id', + loggedIn: true, + userId: 'tracking-id', + }), + feature: expect.objectContaining({ + defaultValue: true, + id: 'layout_v2', + }), + }), + ); + }); + + it('fails closed without a tracking id', async () => { + await expect(resolveLayoutV2(createRequest({ cookie: '' }))).resolves.toBe( + false, + ); + expect(getServerFeatureValue).not.toHaveBeenCalled(); + }); + + it('does not allocate the desktop-only layout to mobile requests', async () => { + const request = createRequest({ mobile: '?1' }); + + expect(isDesktopRequest(request)).toBe(false); + await expect(resolveLayoutV2(request)).resolves.toBe(false); + expect(getServerFeatureValue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webapp/__tests__/middleware.ts b/packages/webapp/__tests__/middleware.ts new file mode 100644 index 00000000000..2d8bdc94824 --- /dev/null +++ b/packages/webapp/__tests__/middleware.ts @@ -0,0 +1,46 @@ +/** @jest-environment node */ + +import { NextRequest } from 'next/server'; +import { middleware } from '../middleware'; +import { resolveLayoutV2 } from '../lib/layoutVariantMiddleware'; + +jest.mock('../lib/layoutVariantMiddleware', () => ({ + resolveLayoutV2: jest.fn(), +})); + +const createRequest = (accept = 'text/html'): NextRequest => + new NextRequest('https://app.daily.dev/posts/test-post?ref=test', { + headers: { accept }, + }); + +describe('post middleware', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(resolveLayoutV2).mockResolvedValue(false); + }); + + it('rewrites enabled HTML requests to the layout v2 post page', async () => { + jest.mocked(resolveLayoutV2).mockResolvedValue(true); + + const response = await middleware(createRequest()); + + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://app.daily.dev/layout-v2/posts/test-post?ref=test', + ); + }); + + it('keeps disabled HTML requests on the original post page', async () => { + const response = await middleware(createRequest()); + + expect(response.headers.get('x-middleware-next')).toBe('1'); + }); + + it('keeps markdown negotiation ahead of layout resolution', async () => { + const response = await middleware(createRequest('text/markdown')); + + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://app.daily.dev/api/md/posts/test-post?ref=test', + ); + expect(resolveLayoutV2).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webapp/lib/layoutVariantMiddleware.ts b/packages/webapp/lib/layoutVariantMiddleware.ts new file mode 100644 index 00000000000..94e6e365f8b --- /dev/null +++ b/packages/webapp/lib/layoutVariantMiddleware.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from 'next/server'; +import { getServerFeatureValue } from '@dailydotdev/shared/src/lib/serverFeatureValue'; +import { featureLayoutV2 } from '@dailydotdev/shared/src/lib/serverFeatures'; + +const TRACKING_COOKIE = 'da2'; +const AUTH_SESSION_COOKIES = ['__Secure-dast', 'dast']; +const MOBILE_USER_AGENT = + /Android|iPhone|iPad|iPod|IEMobile|Opera Mini|Mobile/i; + +export const isDesktopRequest = (req: NextRequest): boolean => { + const clientHint = req.headers.get('sec-ch-ua-mobile'); + + if (clientHint === '?1') { + return false; + } + + return !MOBILE_USER_AGENT.test(req.headers.get('user-agent') ?? ''); +}; + +export const resolveLayoutV2 = async (req: NextRequest): Promise => { + const identifier = req.cookies.get(TRACKING_COOKIE)?.value; + + // The client only evaluates layout v2 on laptop+. Missing hints can produce + // false positives, so recognizable mobile requests stay on the v1 route. + if (!identifier || !isDesktopRequest(req)) { + return false; + } + + return getServerFeatureValue({ + attributes: { + deviceId: identifier, + loggedIn: AUTH_SESSION_COOKIES.some((cookie) => req.cookies.has(cookie)), + mobile: false, + platform: 'webapp', + url: req.url, + userId: identifier, + version: process.env.CURRENT_VERSION, + }, + clientKey: process.env.GROWTHBOOK_CLIENT_KEY, + feature: featureLayoutV2, + }); +}; diff --git a/packages/webapp/middleware.ts b/packages/webapp/middleware.ts index 6dbfa897ced..f06c18e13d2 100644 --- a/packages/webapp/middleware.ts +++ b/packages/webapp/middleware.ts @@ -1,29 +1,37 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { acceptsMarkdown } from './lib/contentNegotiation'; +import { resolveLayoutV2 } from './lib/layoutVariantMiddleware'; import { POST_MARKDOWN_PATH, RESERVED_POST_SLUGS } from './lib/markdownRoutes'; const POSTS_PREFIX = '/posts/'; +const LAYOUT_V2_POSTS_PREFIX = '/layout-v2/posts/'; export const config = { matcher: '/posts/:id', }; -export function middleware(req: NextRequest): NextResponse { +export async function middleware(req: NextRequest): Promise { const { pathname } = req.nextUrl; const id = pathname.slice(POSTS_PREFIX.length); // `.md` URLs are handled by the beforeFiles rewrite, which runs after // middleware. Rewriting here too would pass the id along with its suffix. - if ( - !id || - id.endsWith('.md') || - RESERVED_POST_SLUGS.includes(id) || - !acceptsMarkdown(req.headers.get('accept')) - ) { + if (!id || id.endsWith('.md') || RESERVED_POST_SLUGS.includes(id)) { return NextResponse.next(); } + if (!acceptsMarkdown(req.headers.get('accept'))) { + if (!(await resolveLayoutV2(req))) { + return NextResponse.next(); + } + + const url = req.nextUrl.clone(); + url.pathname = `${LAYOUT_V2_POSTS_PREFIX}${id}`; + + return NextResponse.rewrite(url); + } + const url = req.nextUrl.clone(); url.pathname = `${POST_MARKDOWN_PATH}/${id}`; diff --git a/packages/webapp/next.config.ts b/packages/webapp/next.config.ts index d4a153f1c7a..156848336d7 100644 --- a/packages/webapp/next.config.ts +++ b/packages/webapp/next.config.ts @@ -263,6 +263,12 @@ const nextConfig: NextConfig = { destination: '/posts/:id', permanent: false, }, + // layout v2 pages are selected through middleware only + { + source: '/layout-v2/:path*', + destination: '/:path*', + permanent: false, + }, // so we can't access /plus/gift route directly { source: '/plus/gift', diff --git a/packages/webapp/pages/layout-v2/posts/[id].tsx b/packages/webapp/pages/layout-v2/posts/[id].tsx new file mode 100644 index 00000000000..c2ee74dd91c --- /dev/null +++ b/packages/webapp/pages/layout-v2/posts/[id].tsx @@ -0,0 +1,23 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + getStaticPaths, + getStaticProps, + PostPage, + postPageLayoutProps, +} from '../../posts/[id]/index'; +import type { Props } from '../../posts/[id]/index'; +import { getLayout } from '../../../components/layouts/MainLayout'; + +const LayoutV2PostPage = (props: Props): ReactElement => ( + +); + +LayoutV2PostPage.getLayout = getLayout; +LayoutV2PostPage.layoutProps = { + ...postPageLayoutProps, + layoutVariant: 'v2', +}; + +export { getStaticPaths, getStaticProps }; +export default LayoutV2PostPage; diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 955659d6d19..4b5c1f012dd 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -52,6 +52,7 @@ import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditio import { isPostRedesignEligible } from '@dailydotdev/shared/src/hooks/post/usePostRedesign'; import { featurePostRedesign } from '@dailydotdev/shared/src/lib/featureManagement'; import { PostFocusCard } from '@dailydotdev/shared/src/components/post/focus/PostFocusCard'; +import type { MainLayoutProps } from '@dailydotdev/shared/src/components/MainLayout'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -127,6 +128,7 @@ export interface Props extends DynamicSeoProps { initialData?: PostData; topComments?: Comment[]; error?: ApiError; + isLayoutV2?: boolean; } type PostContentComponent = ComponentType; @@ -169,6 +171,7 @@ export const PostPage = ({ initialData, topComments, error, + isLayoutV2, }: Props): ReactElement => { useJoinReferral(); const { logEvent } = useLogContext(); @@ -199,6 +202,10 @@ export const PostPage = ({ const featureTheme = useFeatureTheme(); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', + // PageBodyContainer uses `m-auto` for horizontal centering. Inside the v2 + // flex card, its auto top margin consumes spare height and pushes the + //
/