diff --git a/apps/web/src/app/signup/_components/signup-layout-client.tsx b/apps/web/src/app/signup/_components/signup-layout-client.tsx
index 842c3da41e..21655fcef9 100644
--- a/apps/web/src/app/signup/_components/signup-layout-client.tsx
+++ b/apps/web/src/app/signup/_components/signup-layout-client.tsx
@@ -33,6 +33,20 @@ export function SignupLayoutClient({ children }: PropsWithChildren) {
+ {/* Hoisted into
by React. The hero 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 && (
+
+ )}
+
{!hideHeader && (
diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx
index 41d85cb078..9f66c2a8e6 100644
--- a/apps/web/src/app/signup/page.tsx
+++ b/apps/web/src/app/signup/page.tsx
@@ -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",
@@ -49,6 +52,23 @@ export default async function Page({
return (
+ {/* Hoisted into by React. The card 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) => (
+
+ ))}
{options.map((option) => (
diff --git a/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx b/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx
new file mode 100644
index 0000000000..32699ec76e
--- /dev/null
+++ b/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx
@@ -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 elements into , 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(content);
+
+ 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 lazy so hidden-on-mobile never fetches it", () => {
+ // The preload's media query is what scopes the fetch to desktop; the
+ // itself must stay lazy, or an eager display:none image would
+ // download the file on mobile anyway.
+ usePathnameMock.mockReturnValue("/signup");
+ const { container } = render(content);
+
+ 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(content);
+
+ 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 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();
+ });
+ });
+});