Skip to content

Homepage: eager first trending thumbnail, Turnstile only on intent - #1604

Merged
feruzm merged 1 commit into
developfrom
perf/home-lcp-turnstile
Aug 21, 2026
Merged

Homepage: eager first trending thumbnail, Turnstile only on intent#1604
feruzm merged 1 commit into
developfrom
perf/home-lcp-turnstile

Conversation

@feruzm

@feruzm feruzm commented Aug 21, 2026

Copy link
Copy Markdown
Member

Two homepage costs measured with PageSpeed on mobile.

  1. The first "Trending now" thumbnail is the mobile LCP element. It streamed in through Suspense with loading="lazy" and no priority hint, so it was not discoverable from the initial document and waited for layout (about 1.3 s of resource load delay in the breakdown). The first card that actually has a thumbnail is now loading="eager" + fetchpriority="high" (React hoists a matching preload for it); the rest stay lazy. A text-only post at the top no longer takes the hint away from the card the reader sees.

  2. LandingSubscribeForm mounted the Turnstile widget as soon as the page hydrated for anyone without an active user: about 560 KB of third-party script and challenge payload, plus main-thread time, for a form at the bottom of the page that most visitors never reach. The widget now mounts on the first interaction with the form (focus, pointer, touch, typing, or a submit attempt, so the 403 retry for a signed-in caller still reveals it). Its 300x65 slot is reserved from the first paint so the late mount does not push the button to a new line, and submit stays gated on the token as before.

Test plan

  • landing-page.spec.tsx: first-with-thumbnail gets eager + high and the others stay lazy; Turnstile is absent until focus, typing or a submit attempt; a signed-in caller sees the widget only after a 403. 23 tests pass.
  • next lint and tsc --noEmit clean.

Closes #1594

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@feruzm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf1cd578-5213-44cc-b9d4-5b7ec598847e

📥 Commits

Reviewing files that changed from the base of the PR and between a03fdb9 and 9c610ce.

📒 Files selected for processing (3)
  • apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx
  • apps/web/src/app/_components/landing-page/landing-trending.tsx
  • apps/web/src/specs/features/landing-page.spec.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Homepage: prioritize first trending thumbnail and mount Turnstile on intent

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Prioritize mobile LCP by eager-loading first trending post thumbnail.
• Lazy-mount Turnstile after newsletter form interaction and reserve its layout slot.
• Add tests covering thumbnail priority hints and Turnstile intent-driven mounting.
Diagram

graph TD
  A(["Homepage '/' "]) --> B["LandingTrending"] --> C["LCP thumbnail (eager/high)"]
  A --> D["LandingSubscribeForm"] --> E{"Engaged?"} --> F["Turnstile widget"] --> G{{"Turnstile CDN"}}
  subgraph Legend
    direction LR
    _page(["Page"]) ~~~ _cmp["Component"] ~~~ _dec{"Decision"} ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Mount Turnstile on viewport entry (IntersectionObserver)
  • ➕ Still avoids loading Turnstile for bounce traffic
  • ➕ Can prefetch challenge slightly before interaction
  • ➖ More complex and timing-sensitive than intent-based gating
  • ➖ Still loads third-party code for users who scroll past but never subscribe
2. Always prioritize index 0 thumbnail (no image-aware LCP selection)
  • ➕ Simpler logic and no additional array preprocessing
  • ➖ Fails when the first entry is text-only; priority hints would be wasted
  • ➖ Can regress LCP by deprioritizing the first visible image
3. Switch trending images to Next/Image with priority
  • ➕ Built-in preload/priority ergonomics and image optimization pipeline
  • ➖ Potentially larger refactor and different caching/loader behavior
  • ➖ May be unnecessary if current + fetchPriority achieves targets

Recommendation: Current approach is the best fit: selecting the first thumbnail-bearing card for eager/high ensures the real mobile LCP candidate is discoverable, while keeping remaining thumbnails lazy. Intent-based Turnstile mounting minimizes third-party cost for the majority of visitors, and the reserved 300×65 slot mitigates layout shift without needing more complex viewport heuristics.

Files changed (3) +172 / -14

Enhancement (2) +54 / -14
landing-subscribe-form.tsxDefer Turnstile mount until subscribe form interaction +36/-10

Defer Turnstile mount until subscribe form interaction

• Adds an 'engaged' intent gate (focus/pointer/touch/typing/submit) so the Turnstile widget is only mounted when the user interacts with the form. Reserves the widget slot size upfront to prevent layout shifts, while keeping submit token-gating behavior intact for captcha-required cases.

apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx

landing-trending.tsxEager-load the first visible trending thumbnail for LCP +18/-4

Eager-load the first visible trending thumbnail for LCP

• Computes the first card that actually has a thumbnail and marks only that image 'loading="eager"' with 'fetchPriority="high"'. Keeps all other thumbnails lazy to avoid increasing overall page weight while improving mobile LCP behavior.

apps/web/src/app/_components/landing-page/landing-trending.tsx

Tests (1) +118 / -0
landing-page.spec.tsxAdd regression tests for LCP thumbnail hints and Turnstile intent gating +118/-0

Add regression tests for LCP thumbnail hints and Turnstile intent gating

• Introduces tests asserting only the first thumbnail-bearing trending image is eager/high-priority. Adds subscribe-form tests verifying Turnstile is absent until interaction and still appears on a forced 403 retry path for signed-in users.

apps/web/src/specs/features/landing-page.spec.tsx

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Captcha slot not reserved 🐞 Bug ☼ Reliability
Description
The Turnstile slot is only rendered when needsCaptcha is true, so a signed-in user who only
becomes captcha-required after a 403 will have the 300×65 slot inserted late, potentially shifting
the submit button after the error. This contradicts the intent/comment that the slot is reserved
“from the first paint” to avoid pushing the button under the user’s finger.
Code

apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[R152-156]

+        // The slot is reserved from the first paint (the managed widget is
+        // 300x65) so the late mount does not push the button to a new line
+        // under the reader's finger.
+        <div className="min-w-[300px] min-h-[65px]">
+          {engaged && (
Evidence
The file computes needsCaptcha based on activeUser and captchaRequired. A 403 error sets
captchaRequired to true, but the reserved Turnstile slot is only rendered under `{needsCaptcha &&
(...)}`; therefore for signed-in users the slot is absent until after the 403, at which point it is
inserted and can shift layout.

apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[39-41]
apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[58-66]
apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[91-107]
apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[122-168]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The Turnstile container slot (`min-w-[300px] min-h-[65px]`) is only present when `needsCaptcha` is true. For signed-in users, `needsCaptcha` starts false, but can flip to true after a failed submit (403 → `setCaptchaRequired(true)`), which inserts the slot late and can shift layout.

### Issue Context
- `needsCaptcha = !activeUser || captchaRequired` means signed-in users do not see the slot at first.
- A 403 sets `captchaRequired` true, making `needsCaptcha` true after the response.
- The code comment says the slot is reserved from the first paint to avoid pushing the button, but that’s not true for the signed-in 403 path.

### Fix Focus Areas
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[39-41]
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[58-66]
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[91-107]
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[122-168]

### Implementation guidance
Adjust rendering so the **slot** is reserved once the user has engaged with the form (or at least once a submit attempt occurs), even if `needsCaptcha` is currently false; keep mounting the **Turnstile widget** itself gated on `needsCaptcha`.

Example pattern:
- Render the slot when `engaged` is true (and/or while `loading`), not only when `needsCaptcha` is true.
- Render `<Turnstile ... />` only when `needsCaptcha && engaged`.

This prevents late insertion of the container after 403 while preserving the "Turnstile only on intent" goal.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This changes two distinct runtime paths—LCP image prioritization and Turnstile interaction/403 gating—with meaningful behavioral and security-adjacent risk, but the scope remains small enough for a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx Outdated
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces anonymous homepage startup cost by deferring Turnstile until form interaction and prioritizes the first rendered trending thumbnail.

  • Reserves the expected captcha slot while delaying third-party widget initialization.
  • Preserves token gating and signed-in 403 challenge recovery.
  • Marks the first available trending image eager with high fetch priority.
  • Adds coverage for thumbnail selection and deferred captcha mounting.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx Defers Turnstile mounting until user intent while preserving the existing captcha token and retry flow.
apps/web/src/app/_components/landing-page/landing-trending.tsx Precomputes card thumbnails and prioritizes only the first card that renders an image.
apps/web/src/specs/features/landing-page.spec.tsx Adds focused coverage for image priority, deferred challenge mounting, submit intent, and signed-in 403 recovery.

Reviews (3): Last reviewed commit: "Homepage: eager first trending thumbnail..." | Re-trigger Greptile

Comment thread apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx Outdated
@feruzm
feruzm force-pushed the perf/home-lcp-turnstile branch from aaff611 to c263035 Compare August 21, 2026 06:25
@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Turnstile slot can overflow 🐞 Bug ≡ Correctness
Description
The new reserved Turnstile placeholder uses min-w-[300px], which can exceed the footer column’s
available width on narrow viewports (e.g., 320px wide screens with px-4), causing horizontal page
overflow/scrollbars even before the widget mounts.
Code

apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[R155-156]

+        <div className="min-w-[300px] min-h-[65px]">
+          {engaged && (
Relevance

●●● Strong

Recent accepted landing-form fixes show correctness issues in this component get addressed; overflow
is a deterministic layout bug.

PR-#1579

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR introduces a fixed-min-width placeholder (min-w-[300px]). The landing footer container
applies px-4 and renders the form as flex flex-wrap; on narrow viewports the content width can
drop below 300px, so a min-width:300px flex item can exceed the container and create horizontal
overflow.

apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[151-167]
apps/web/src/app/_components/landing-page/index.tsx[281-285]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The Turnstile placeholder reserves space with `min-w-[300px]`, which can be wider than the footer’s content box on small screens (notably 320px-wide devices after `px-4` padding). Because it’s a flex item, this can force horizontal overflow and introduce a page-level horizontal scrollbar.

### Issue Context
The landing footer wraps `LandingSubscribeForm` in a flex layout and applies horizontal padding. The Turnstile widget is ~300px wide, but the placeholder currently *enforces* that width via `min-w`, even when the widget isn’t mounted.

### Fix Focus Areas
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[152-166]

### Suggested change
- Replace `min-w-[300px]` with a responsive-safe sizing rule that won’t exceed container width, e.g.
 - `className="w-[300px] max-w-full min-h-[65px]"` (preferred over `min-w`), and/or
 - add `overflow-x-auto` or `overflow-x-hidden` to prevent page-level overflow if the embedded widget can’t shrink.

This keeps the “reserve space” behavior while preventing the placeholder itself from forcing horizontal overflow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. useActiveAccount mocked in test 📘 Rule violation ▣ Testability
Description
The new LandingSubscribeForm regression test mocks the internal hook useActiveAccount, which
violates the policy that unit tests should only mock external package dependencies. This increases
coupling to internal implementation and makes refactors harder.
Code

apps/web/src/specs/features/landing-page.spec.tsx[R302-306]

+  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);
Relevance

● Weak

Multiple recent close precedents reject mocking internal hooks like useActiveAccount in unit tests.

PR-#1521
PR-#1503
PR-#1487

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance ID 2668008 disallows mocking internal application modules in unit tests. The added test
overrides the return value of the internal hook useActiveAccount to simulate a signed-in user.

Rule 2668008: Mock only external package dependencies with vi.fn in unit tests
apps/web/src/specs/features/landing-page.spec.tsx[302-323]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A test mocks the internal application hook `useActiveAccount` via Vitest mocking APIs. Per policy, unit tests should mock only external package dependencies; internal modules should be exercised via their public interfaces/test harnesses.

## Issue Context
The test `shows the widget to a signed-in caller only after the service answers 403 (#1594)` changes `useActiveAccount` return values to simulate a signed-in user.

## Fix Focus Areas
- apps/web/src/specs/features/landing-page.spec.tsx[302-323]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 84 rules
✅ Skills: 6 invoked
  add-feature
  add-query
  add-sdk-mutation
  add-test
  code-review
  debug
Review mode: ⚖️ Balanced: This changes two runtime homepage paths—LCP image prioritization and Turnstile-gated subscription behavior—with several interaction and retry states, so it carries meaningful behavioral risk but is not broad or defect-dense enough to justify redundant extended review.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +155 to +156
<div className="min-w-[300px] min-h-[65px]">
{engaged && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Turnstile slot can overflow 🐞 Bug ≡ Correctness

The new reserved Turnstile placeholder uses min-w-[300px], which can exceed the footer column’s
available width on narrow viewports (e.g., 320px wide screens with px-4), causing horizontal page
overflow/scrollbars even before the widget mounts.
Agent Prompt
### Issue description
The Turnstile placeholder reserves space with `min-w-[300px]`, which can be wider than the footer’s content box on small screens (notably 320px-wide devices after `px-4` padding). Because it’s a flex item, this can force horizontal overflow and introduce a page-level horizontal scrollbar.

### Issue Context
The landing footer wraps `LandingSubscribeForm` in a flex layout and applies horizontal padding. The Turnstile widget is ~300px wide, but the placeholder currently *enforces* that width via `min-w`, even when the widget isn’t mounted.

### Fix Focus Areas
- apps/web/src/app/_components/landing-page/landing-subscribe-form.tsx[152-166]

### Suggested change
- Replace `min-w-[300px]` with a responsive-safe sizing rule that won’t exceed container width, e.g.
  - `className="w-[300px] max-w-full min-h-[65px]"` (preferred over `min-w`), and/or
  - add `overflow-x-auto` or `overflow-x-hidden` to prevent page-level overflow if the embedded widget can’t shrink.

This keeps the “reserve space” behavior while preventing the placeholder itself from forcing horizontal overflow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken: the slot is now w-[300px] max-w-full min-h-[65px], so it never exceeds the column on a 320px screen while still reserving the widget's height.

On a phone the first "Trending now" thumbnail is the largest element in
the viewport. It streamed in through Suspense with loading=lazy and no
priority hint, so the preload scanner never saw it and it waited for
layout: PageSpeed reported about 1.3 s of load delay on the LCP element.
The first card that has a thumbnail is now eager with fetchpriority=high
(React also hoists a preload for it); the rest stay lazy.

The newsletter form mounted the Turnstile widget as soon as the page
hydrated for anyone without an active user, about 560 KB of third-party
script and challenge payload for a form at the bottom of the page. The
widget now mounts on the first interaction with the form (focus, pointer,
touch, typing, or a submit attempt, so the 403 retry path for a signed-in
caller still reveals it). Its 300x65 slot is reserved from the first
paint so the late mount does not shift the button, and submit stays
gated on the token.

Closes #1594
@feruzm
feruzm force-pushed the perf/home-lcp-turnstile branch from c263035 to 9c610ce Compare August 21, 2026 06:31
@feruzm

feruzm commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

On the testability note about useActiveAccount: the spec does not add a mock for that hook. It is already mocked for every spec in src/specs/setup-any-spec.ts (returning a signed-out account); the new case only changes that existing mock's return value for one test and restores it, which is the only way to exercise the signed-in 403 path without a real account store.

@feruzm
feruzm merged commit ef87721 into develop Aug 21, 2026
9 checks passed
@feruzm
feruzm deleted the perf/home-lcp-turnstile branch August 21, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Homepage: LCP thumbnail is lazy-loaded and Turnstile mounts for every anonymous visitor

1 participant