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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export function LandingSubscribeForm() {
const [captchaRequired, setCaptchaRequired] = useState(false);
const needsCaptcha = !activeUser || captchaRequired;

// The widget is only mounted once the reader touches the form. Turnstile costs
// ~560 KB of third-party script and challenge payload plus main-thread time,
// and it used to load for every signed-out visitor of the homepage, for a form
// at the bottom of the page that most of them never reach (#1594). Focus,
// pointer, touch and typing (autofill can skip focus) all count as intent; the
// challenge then resolves while the address is being typed. Submitting counts
// too, so the 403 retry path for a signed-in caller still reveals the widget.
const [engaged, setEngaged] = useState(false);
const engage = () => setEngaged(true);

// Single-use tokens: a retry that reuses one fails as though the service were down.
const resetCaptcha = () => {
setCaptchaToken("");
Expand All @@ -47,6 +57,7 @@ export function LandingSubscribeForm() {

const handleSubscribe = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
engage();
// The disabled button is only the visible half: a form can still be submitted from
// the keyboard while its button is disabled, and posting an empty token would spend
// a round trip to be told 403.
Expand Down Expand Up @@ -109,12 +120,20 @@ export function LandingSubscribeForm() {
}

return (
<form onSubmit={handleSubscribe}>
<form
onSubmit={handleSubscribe}
onFocusCapture={engage}
onPointerDownCapture={engage}
onTouchStartCapture={engage}
>
<input
type="email"
placeholder={i18next.t("landing-page.enter-your-email-adress")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
engage();
setEmail(e.target.value);
}}
required={true}
autoComplete="email"
aria-label={i18next.t("landing-page.enter-your-email-adress")}
Expand All @@ -130,14 +149,23 @@ export function LandingSubscribeForm() {
<option value="monthly">{i18next.t("newsletter.cadence.monthly")}</option>
</select>
{needsCaptcha && (
<Turnstile
ref={turnstileRef}
sitekey={TURNSTILE_SITEKEY}
action="newsletter-subscribe"
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
onError={() => setCaptchaToken("")}
/>
// For a signed-out reader the challenge is expected, so its slot (the
// managed widget is 300x65) is reserved from the first paint and the
// late mount does not push the button to a new line under the reader's
// finger. A signed-in caller only meets the widget after a 403, as an
// error retry, and that path inserts it late as it did before.
<div className="w-[300px] max-w-full min-h-[65px]">
{engaged && (
<Turnstile
ref={turnstileRef}
sitekey={TURNSTILE_SITEKEY}
action="newsletter-subscribe"
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
onError={() => setCaptchaToken("")}
/>
)}
</div>
)}
<button disabled={loading || (needsCaptcha && !captchaToken)}>
{loading ? (
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/app/_components/landing-page/landing-trending.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ const LIMIT = 8;
*
* prefetchQuery has a built-in SSR timeout and swallows errors (returns
* undefined), so a slow/failed RPC degrades to "no strip" rather than breaking
* "/". Thumbnails are below the hero and lazy-loaded, keeping the page light.
* "/". Thumbnails are lazy-loaded except the first: on a phone the hero is short
* enough that the first card's thumbnail is the largest thing in the viewport,
* i.e. the LCP element. Streamed in through Suspense and marked lazy it was
* invisible to the preload scanner and waited for layout, which PageSpeed
* reported as ~1.3 s of load delay (#1594). Eager + fetchpriority=high lets the
* browser request it the moment its markup arrives.
*/
export async function LandingTrending() {
const data = (await prefetchQuery(
Expand All @@ -38,6 +43,15 @@ export async function LandingTrending() {
return null;
}

const cards = entries.map((entry) => ({
entry,
thumb: catchPostImage(entry, 320, 180, "match")
}));
// The LCP candidate is the first card that actually renders a thumbnail: a
// text-only post at the top would otherwise take the hint while the next
// card's image, the one the reader sees, stays lazy.
const lcpIndex = cards.findIndex((card) => card.thumb);

return (
<section className="landing-trending relative z-[2] w-full" aria-labelledby="trending-heading">
<div className="inner max-w-[1200px] mx-auto w-full px-4 py-10">
Expand All @@ -54,11 +68,10 @@ export async function LandingTrending() {
</div>

<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 p-0 m-0 list-none">
{entries.map((entry) => {
{cards.map(({ entry, thumb }, index) => {
// Canonical entry URL is the bare /@author/permlink form; the
// category-prefixed path 307-redirects to it, so link direct.
const href = `/@${entry.author}/${entry.permlink}`;
const thumb = catchPostImage(entry, 320, 180, "match");
const tag = entry.community_title || `#${entry.category}`;
return (
<li key={`${entry.author}/${entry.permlink}`}>
Expand All @@ -70,7 +83,8 @@ export async function LandingTrending() {
<img
src={thumb}
alt=""
loading="lazy"
loading={index === lcpIndex ? "eager" : "lazy"}
fetchPriority={index === lcpIndex ? "high" : undefined}
decoding="async"
width={320}
height={180}
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/specs/features/landing-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ import { LandingDownloadLinks } from "@/app/_components/landing-page/landing-dow
import { LandingExplore } from "@/app/_components/landing-page/landing-explore";
import { LandingTrending } from "@/app/_components/landing-page/landing-trending";
import { success, error as errorFn } from "@/features/shared/feedback";
import { useActiveAccount } from "@/core/hooks/use-active-account";
import { NewsletterApiError } from "@/features/newsletter";

describe("LandingHeroActions", () => {
it("renders explore and get started links", () => {
Expand Down Expand Up @@ -189,6 +191,70 @@ describe("LandingTrending", () => {
mockPrefetchQuery.mockResolvedValue([]);
expect(await LandingTrending()).toBeNull();
});

it("loads only the first thumbnail eagerly with high priority, the rest lazily (#1594)", async () => {
const entry = (i: number) => ({
author: `author${i}`,
permlink: `post-${i}`,
title: `Post ${i}`,
category: "life",
body: "",
json_metadata: { tags: ["life"], image: [`https://images.example/${i}.jpg`] },
created: "2024-01-01T00:00:00"
});
mockPrefetchQuery.mockResolvedValue([entry(1), entry(2), entry(3)]);

const { container } = render(await LandingTrending());
const imgs = Array.from(container.querySelectorAll("img"));
expect(imgs).toHaveLength(3);
// The first card's thumbnail is the mobile LCP element: it must be
// discoverable and prioritised, not deferred behind layout.
expect(imgs[0]).toHaveAttribute("loading", "eager");
expect(imgs[0]).toHaveAttribute("fetchpriority", "high");
for (const img of imgs.slice(1)) {
expect(img).toHaveAttribute("loading", "lazy");
expect(img).not.toHaveAttribute("fetchpriority");
}
});

it("gives the hint to the first card that has a thumbnail when the top post has none (#1594)", async () => {
mockPrefetchQuery.mockResolvedValue([
{
author: "textonly",
permlink: "no-image",
title: "Words only",
category: "life",
body: "just text",
json_metadata: { tags: ["life"] },
created: "2024-01-01T00:00:00"
},
{
author: "photog",
permlink: "with-image",
title: "A photo",
category: "life",
body: "",
json_metadata: { tags: ["life"], image: ["https://images.example/p.jpg"] },
created: "2024-01-01T00:00:00"
},
{
author: "other",
permlink: "another",
title: "Another photo",
category: "life",
body: "",
json_metadata: { tags: ["life"], image: ["https://images.example/q.jpg"] },
created: "2024-01-01T00:00:00"
}
]);

const { container } = render(await LandingTrending());
const imgs = Array.from(container.querySelectorAll("img"));
expect(imgs).toHaveLength(2);
expect(imgs[0]).toHaveAttribute("loading", "eager");
expect(imgs[0]).toHaveAttribute("fetchpriority", "high");
expect(imgs[1]).toHaveAttribute("loading", "lazy");
});
});

describe("LandingSubscribeForm", () => {
Expand All @@ -205,6 +271,58 @@ describe("LandingSubscribeForm", () => {
expect(screen.getByText("landing-page.send")).toBeInTheDocument();
});

it("does not mount Turnstile until the reader touches the form (#1594)", () => {
render(<LandingSubscribeForm />);
// The mock records its onVerify when it renders; nothing rendered yet.
expect(captcha.verify).toBeNull();
// The button is still gated on the token, so nothing can be submitted.
expect(screen.getByRole("button", { name: "landing-page.send" })).toBeDisabled();

fireEvent.focus(screen.getByPlaceholderText("landing-page.enter-your-email-adress"));
expect(captcha.verify).not.toBeNull();
});

it("mounts Turnstile when the address is typed or pasted without a focus event (#1594)", () => {
render(<LandingSubscribeForm />);
expect(captcha.verify).toBeNull();
fireEvent.change(screen.getByPlaceholderText("landing-page.enter-your-email-adress"), {
target: { value: "a@b.c" }
});
expect(captcha.verify).not.toBeNull();
});

it("mounts Turnstile on a submit attempt, so a forced 403 retry can still be challenged (#1594)", () => {
render(<LandingSubscribeForm />);
const input = screen.getByPlaceholderText("landing-page.enter-your-email-adress");
fireEvent.submit(input.closest("form")!);
expect(captcha.verify).not.toBeNull();
expect(mockSubscribe).not.toHaveBeenCalled();
});

it("shows the widget to a signed-in caller only after the service answers 403 (#1594)", async () => {
vi.mocked(useActiveAccount).mockReturnValue({
activeUser: { username: "alice" },
username: "alice"
} as never);
mockSubscribe.mockRejectedValueOnce(new NewsletterApiError("captcha", 403));
try {
render(<LandingSubscribeForm />);
const input = screen.getByPlaceholderText("landing-page.enter-your-email-adress");
fireEvent.change(input, { target: { value: "alice@example.com" } });
// Signed in: no challenge is rendered, and the button is not token-gated.
expect(captcha.verify).toBeNull();
expect(screen.getByRole("button", { name: "landing-page.send" })).not.toBeDisabled();

fireEvent.submit(input.closest("form")!);
await waitFor(() => expect(errorFn).toHaveBeenCalledWith("newsletter.error-captcha"));
// The 403 reveals the widget so the retry can carry a token.
expect(captcha.verify).not.toBeNull();
} finally {
vi.mocked(useActiveAccount).mockReset();
vi.mocked(useActiveAccount).mockReturnValue({ activeUser: null, username: null } as never);
}
});

it("subscribes the address to the site digest through the service and shows check-your-inbox on pending", async () => {
// What the service returns to an unproven caller: this and nothing more.
mockSubscribe.mockResolvedValue({ status: "pending_confirmation" });
Expand Down
Loading