Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/web/src/app/signup/_components/signup-layout-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export function SignupLayoutClient({ children }: PropsWithChildren) {
<Feedback />
<Navbar experimental={true} />

{/* Hoisted into <head> by React. The hero <img> below must stay lazy:
its container is display:none under md, and a lazy image with no box
is never fetched, which keeps this ~47KB file off mobile. The media
query scopes the early fetch to the viewports that paint it. */}
{!hideHeader && (
<link
rel="preload"
as="image"
href="/assets/signup-main.svg"
media="(min-width: 768px)"
fetchPriority="high"
/>
)}

<div className="container mb-24 md:mb-0 px-2 mx-auto mt-6 md:mt-8">
{!hideHeader && (
<div className="grid grid-cols-12 mb-10 items-center gap-4 md:gap-6 lg:gap-8 xl:gap-10">
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ export async function generateMetadata(
return PagesMetadataGenerator.getForPage("signup");
}

const options: { key: string; image: string; href: string; desktopOnly?: boolean }[] = [
const options: { key: string; image: string; href: string; desktopOnly?: boolean; mobileLcp?: boolean }[] = [
{
key: "free",
image: "/assets/undraw-mailbox.svg",
href: "/signup/free"
href: "/signup/free",
// The desktop hero is hidden on mobile, so this first card's illustration
// is the page's LCP element there; preload it for mobile viewports.
mobileLcp: true
},
{
key: "premium",
Expand Down Expand Up @@ -49,6 +52,23 @@ export default async function Page({

return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 py-6">
{/* Hoisted into <head> by React. The card <img>s stay lazy: at md+ the
layout's hero is the LCP element and an unconditional high-priority
card fetch would compete with it, so only mobile (where the hero is
hidden and this card IS the LCP element) gets the early fetch. The
media query complements the hero preload's (min-width: 768px). */}
{options
.filter((option) => option.mobileLcp)
.map((option) => (
<link
key={`${option.key}-preload`}
rel="preload"
as="image"
href={option.image}
media="(max-width: 767px)"
fetchPriority="high"
/>
))}
{options.map((option) => (
<div key={option.key} className={`bg-white dark:bg-dark-200 rounded-2xl p-6 flex flex-col justify-between ${option.desktopOnly ? "hidden md:flex" : ""}`}>
<div className="uppercase opacity-50 font-bold text-sm">
Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { cleanup, render } from "@testing-library/react";
import "@testing-library/jest-dom";

// The global i18next mock has no event emitter, and the layout subscribes to
// languageChanged on mount.
vi.mock("i18next", () => ({
default: {
t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key,
on: vi.fn(),
off: vi.fn()
}
}));

const usePathnameMock = vi.fn(() => "/signup");
vi.mock("next/navigation", () => ({
usePathname: () => usePathnameMock(),
useParams: () => ({}),
useRouter: () => ({ push: vi.fn() })
}));
vi.mock("@/features/shared/feedback", () => ({
Feedback: () => null
}));
vi.mock("@/features/shared/navbar", () => ({
Navbar: () => null
}));
vi.mock("@/features/metadata", () => ({
PagesMetadataGenerator: { getForPage: vi.fn(async () => ({})) }
}));

import { SignupLayoutClient } from "@/app/signup/_components/signup-layout-client";
import SignupPage from "@/app/signup/page";

// React hoists rendered <link> elements into <head>, so query the document.
const heroPreload = () =>
document.head.querySelector('link[rel="preload"][href="/assets/signup-main.svg"]');

describe("signup LCP images", () => {
afterEach(() => {
cleanup();
// Hoisted links survive RTL cleanup; drop them so cases stay isolated.
document.head.querySelectorAll('link[rel="preload"]').forEach((el) => el.remove());
});

describe("desktop hero (signup-main.svg)", () => {
it("preloads the hero for md+ viewports at high priority", () => {
usePathnameMock.mockReturnValue("/signup");
render(<SignupLayoutClient>content</SignupLayoutClient>);

const link = heroPreload();
expect(link).not.toBeNull();
expect(link).toHaveAttribute("as", "image");
expect(link).toHaveAttribute("media", "(min-width: 768px)");
expect(link).toHaveAttribute("fetchpriority", "high");
});

it("keeps the hero <img> lazy so hidden-on-mobile never fetches it", () => {
// The preload's media query is what scopes the fetch to desktop; the
// <img> itself must stay lazy, or an eager display:none image would
// download the file on mobile anyway.
usePathnameMock.mockReturnValue("/signup");
const { container } = render(<SignupLayoutClient>content</SignupLayoutClient>);

const hero = container.querySelector('img[src="/assets/signup-main.svg"]');
expect(hero).not.toBeNull();
expect(hero).toHaveAttribute("loading", "lazy");
});

it("emits no preload on sub-pages that hide the header", () => {
usePathnameMock.mockReturnValue("/signup/free");
const { container } = render(<SignupLayoutClient>content</SignupLayoutClient>);

expect(heroPreload()).toBeNull();
expect(container.querySelector('img[src="/assets/signup-main.svg"]')).toBeNull();
});
});

describe("option cards", () => {
it("preloads the first card's illustration for mobile viewports only", async () => {
const ui = await SignupPage({ searchParams: Promise.resolve({}) });
render(ui);

const link = document.head.querySelector(
'link[rel="preload"][href="/assets/undraw-mailbox.svg"]'
);
expect(link).not.toBeNull();
expect(link).toHaveAttribute("as", "image");
// At md+ the hero is the LCP element; without the media gate this
// preload would compete with it on desktop.
expect(link).toHaveAttribute("media", "(max-width: 767px)");
expect(link).toHaveAttribute("fetchpriority", "high");
});

it("keeps every card <img> lazy and preloads no other card", async () => {
const ui = await SignupPage({ searchParams: Promise.resolve({}) });
const { container } = render(ui);

const mailbox = container.querySelector('img[src="/assets/undraw-mailbox.svg"]');
expect(mailbox).not.toBeNull();
expect(mailbox).toHaveAttribute("loading", "lazy");

const creditCard = container.querySelector('img[src="/assets/undraw-credit-card.svg"]');
expect(creditCard).not.toBeNull();
expect(creditCard).toHaveAttribute("loading", "lazy");
expect(
document.head.querySelector('link[rel="preload"][href="/assets/undraw-credit-card.svg"]')
).toBeNull();
});
});
});
Loading