From c8eb6d912908614811eaf26447f8a6f0bd43926c Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 26 Aug 2026 17:15:16 +0000 Subject: [PATCH 1/2] signup: fetch the page's LCP images eagerly Field LCP data flags both signup illustrations as slow: the desktop hero (signup-main.svg) and the first option card (undraw-mailbox.svg, the LCP element on mobile where the hero is hidden) were default-lazy. Preload the hero via a media-gated link so mobile keeps skipping the file while desktop fetches it from the head, and mark the first card priority with explicit fetchpriority. --- .../_components/signup-layout-client.tsx | 14 +++ apps/web/src/app/signup/page.tsx | 11 ++- .../signup/signup-lcp-images.spec.tsx | 98 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx 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..aaf6240a66 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; priority?: 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; fetch it eagerly at high priority. + priority: true }, { key: "premium", @@ -60,6 +63,10 @@ export default async function Page({ alt="" width={400} height={400} + priority={option.priority} + // next/image `priority` emits the preload but leaves the + // without fetchpriority; set it explicitly. + fetchPriority={option.priority ? "high" : undefined} className="max-w-[160px] md:max-w-[200px] mx-auto my-6" />
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..c4f710822e --- /dev/null +++ b/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx @@ -0,0 +1,98 @@ +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("loads the first card's illustration eagerly at high priority", 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("fetchpriority", "high"); + expect(mailbox).not.toHaveAttribute("loading", "lazy"); + }); + + it("leaves the remaining card illustrations lazy", async () => { + const ui = await SignupPage({ searchParams: Promise.resolve({}) }); + const { container } = render(ui); + + const creditCard = container.querySelector('img[src="/assets/undraw-credit-card.svg"]'); + expect(creditCard).not.toBeNull(); + expect(creditCard).toHaveAttribute("loading", "lazy"); + }); + }); +}); From cd0ce8b23a69fedcc27780104214aa3db8fa69b1 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 26 Aug 2026 18:35:11 +0000 Subject: [PATCH 2/2] signup: scope the first card's preload to mobile viewports Review finding: priority on the mailbox image applied at every breakpoint, so at md+ its unconditional preload and high-priority fetch competed with the hero, which is the actual desktop LCP element. Mirror the hero's treatment instead: a media-gated mobile preload while the card stays lazy everywhere. --- apps/web/src/app/signup/page.tsx | 27 ++++++++++++++----- .../signup/signup-lcp-images.spec.tsx | 26 +++++++++++++----- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx index aaf6240a66..9f66c2a8e6 100644 --- a/apps/web/src/app/signup/page.tsx +++ b/apps/web/src/app/signup/page.tsx @@ -13,14 +13,14 @@ export async function generateMetadata( return PagesMetadataGenerator.getForPage("signup"); } -const options: { key: string; image: string; href: string; desktopOnly?: boolean; priority?: boolean }[] = [ +const options: { key: string; image: string; href: string; desktopOnly?: boolean; mobileLcp?: boolean }[] = [ { key: "free", image: "/assets/undraw-mailbox.svg", href: "/signup/free", // The desktop hero is hidden on mobile, so this first card's illustration - // is the page's LCP element there; fetch it eagerly at high priority. - priority: true + // is the page's LCP element there; preload it for mobile viewports. + mobileLcp: true }, { key: "premium", @@ -52,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) => (
@@ -63,10 +80,6 @@ export default async function Page({ alt="" width={400} height={400} - priority={option.priority} - // next/image `priority` emits the preload but leaves the - // without fetchpriority; set it explicitly. - fetchPriority={option.priority ? "high" : undefined} className="max-w-[160px] md:max-w-[200px] mx-auto my-6" />
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 index c4f710822e..32699ec76e 100644 --- a/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx +++ b/apps/web/src/specs/features/signup/signup-lcp-images.spec.tsx @@ -76,23 +76,35 @@ describe("signup LCP images", () => { }); describe("option cards", () => { - it("loads the first card's illustration eagerly at high priority", async () => { + it("preloads the first card's illustration for mobile viewports only", async () => { const ui = await SignupPage({ searchParams: Promise.resolve({}) }); - const { container } = render(ui); + render(ui); - const mailbox = container.querySelector('img[src="/assets/undraw-mailbox.svg"]'); - expect(mailbox).not.toBeNull(); - expect(mailbox).toHaveAttribute("fetchpriority", "high"); - expect(mailbox).not.toHaveAttribute("loading", "lazy"); + 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("leaves the remaining card illustrations lazy", async () => { + 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(); }); }); });