Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -102,6 +104,7 @@ function MainLayoutComponent({
canGoBack,
hideFeedbackWidget = false,
topBanner,
layoutVariant,
}: MainLayoutProps): ReactElement | null {
const router = useRouter();
const { logEvent } = useLogContext();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -206,7 +216,8 @@ function MainLayoutComponent({
// `isLaptop` alone made the server emit one and the client skip it, which
// shifted `<main>` 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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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 && (
<MainLayoutHeader
hasBanner={isBannerAvailable}
sidebarRendered={sidebarRendered}
sidebarRendered={layoutVariant === 'v1' ? false : sidebarRendered}
additionalButtons={additionalButtons}
onLogoClick={onLogoClick}
/>
Expand All @@ -350,15 +362,15 @@ 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,
isAuthReady &&
showSidebar &&
(sidebarExpanded || forceSidebarExpanded) &&
(isV2 ? v2ExpandedPadding : !isScreenCentered && 'laptop:!pl-60'),
isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24',
isBannerAvailable && shouldRenderHeader && 'laptop:pt-24',
)}
>
{isAuthReady && isLayoutChromeResolved && showSidebar && (
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/lib/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { JSONValue } from '@growthbook/growthbook';

export class Feature<T extends JSONValue> {
readonly id: string;

readonly defaultValue: T;

constructor(id: string, defaultValue: T) {
this.id = id;
this.defaultValue = defaultValue;
}
}
16 changes: 3 additions & 13 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import type { JSONValue } from '@growthbook/growthbook';
import type { FeedAdTemplate } from './feed';
import type { FeedSettingsKeys } from '../contexts/FeedContext';
import type { PlusItemStatus } from '../components/plus/PlusListItem';
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<T extends JSONValue> {
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),
Expand Down Expand Up @@ -258,8 +250,6 @@ export const featureOnboardingChrome = new Feature<OnboardingChromeVariant>(
OnboardingChromeVariant.Control,
);

export const featureLayoutV2 = new Feature('layout_v2', false);

export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false);

export const featureHeroCards = new Feature<HeroCardsConfig>('hero_cards', {
Expand Down
39 changes: 39 additions & 0 deletions packages/shared/src/lib/serverFeatureValue.ts
Original file line number Diff line number Diff line change
@@ -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<T extends JSONValue> {
attributes: Record<string, unknown>;
clientKey?: string;
feature: Feature<T>;
}

export const getServerFeatureValue = async <T extends JSONValue>({
attributes,
clientKey,
feature,
}: GetServerFeatureValueOptions<T>): Promise<T> => {
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();
}
};
5 changes: 5 additions & 0 deletions packages/shared/src/lib/serverFeatures.ts
Original file line number Diff line number Diff line change
@@ -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);
31 changes: 28 additions & 3 deletions packages/webapp/__tests__/MainLayoutPaintHold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TestBootProvider
client={new QueryClient()}
auth={{ isAuthReady: false, isLoggedIn: false, user: undefined }}
>
<MainLayout>
<MainLayout layoutVariant={forcedVariant}>
<p>prerendered page content</p>
</MainLayout>
</TestBootProvider>,
Expand All @@ -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', () => {
Expand Down
99 changes: 99 additions & 0 deletions packages/webapp/__tests__/layoutVariantMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/** @jest-environment node */

import { NextRequest } from 'next/server';
import { getServerFeatureValue } from '@dailydotdev/shared/src/lib/serverFeatureValue';
import { featureLayoutV2 } from '@dailydotdev/shared/src/lib/serverFeatures';
import {
isDesktopRequest,
isLayoutV2EligiblePath,
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.each([
'/kramer',
'/kramer/work',
'/posts/best-of',
'/posts/best-of/2025/8',
'/tags/javascript/best-of/2025',
'/sources/thenewstack/best-of',
'/gear',
'/jobs/test-job',
'/standups/test-standup',
'/squads/test-squad',
'/squads/discover/featured-category',
'/quiz/ai-fluency',
])('recognizes the v2 route %s', (pathname) => {
expect(isLayoutV2EligiblePath(pathname)).toBe(true);
});

it.each([
'/popular',
'/favicon.ico',
'/settings',
'/kramer/unknown-section',
'/posts/latest',
'/posts/test.md',
'/squads/new',
'/tags/javascript',
])('leaves the non-v2 route %s unchanged', (pathname) => {
expect(isLayoutV2EligiblePath(pathname)).toBe(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: featureLayoutV2,
}),
);
});

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();
});
});
47 changes: 47 additions & 0 deletions packages/webapp/__tests__/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** @jest-environment node */

import { NextRequest } from 'next/server';
import { middleware } from '../middleware';
import { resolveLayoutV2 } from '../lib/layoutVariantMiddleware';

jest.mock('../lib/layoutVariantMiddleware', () => ({
isLayoutV2EligiblePath: jest.fn(() => true),
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();
});
});
Loading
Loading