Skip to content

perf(cwv): cut hydration-time JS & fix LCP image preload (mobile Core Web Vitals) - #795

Merged
feruzm merged 7 commits into
developfrom
perf/core-web-vitals
May 17, 2026
Merged

perf(cwv): cut hydration-time JS & fix LCP image preload (mobile Core Web Vitals)#795
feruzm merged 7 commits into
developfrom
perf/core-web-vitals

Conversation

@feruzm

@feruzmferuzm commented May 17, 2026

Copy link
Copy Markdown
Member

Why

CrUX/GSC field data (2026-05-17): mobile fails Core Web Vitals — origin p75 LCP 3.73 s (Needs improvement, 23 % poor) and INP 324 ms (Needs improvement); CLS 0.09 (Good). Desktop is green. Mobile is the ranking signal and carries ~all our long-tail post SEO traffic.

Root cause is singular and consistent across page types: the LCP element is already in the SSR HTML and downloaded early, but paints ~5 s late because the main thread is blocked by hydration-time JS (PSI: ~584 KB unused JS on posts, 4.1 s bootup, 6.9 s main-thread). So the lever is reducing JS executed during hydration — and it's SEO-safe because nothing deferred here is in the indexable HTML.

Changes

  • perf(entry): code-split post-render enhancements out of first client chunksetupPostEnhancements and its heavy deps (medium-zoom, embed/link enhancers) were statically imported but only run post-hydration inside a setTimeout. Now dynamically imported inside that effect (with a cancelled guard for the async window). Behavior-preserving.
  • perf(entry): lazy-load interaction-gated post action modals — entry-menu's six modals (share, cross-post, edit-history, mute, promote, translate) and entry-vote-btn's vote-slider dialog are strictly boolean-gated; converted to next/dynamic({ ssr:false }) using the existing house pattern (entry-votes/index.tsx). Footer controls / comments auto-load left unchanged (higher-risk UX surface).
  • perf(landing): use plain <img> for priority landing assetsimages.unoptimized=true means next/image did no optimization here; it only added a client component plus a hydration-gated preload pointing at the .png fallback while the <picture> renders the .webp. Plain server <img> (eager, high fetchpriority, intrinsic dimensions) ships in the initial HTML and drops the wasted .png fetch.
  • perf(entry): make LCP image preload match the in-body responsive img — the preload used a fixed catchPostImage(600,500) URL while the in-body LCP <img> selects from srcset+sizes (100vw on mobile), so the high-priority preload fetched a URL the page never renders. Now carries imageSrcSet (buildSrcSet, same /p/<hash>?…&width=<w> format as the body img) + imageSizes.

Validation

  • ESLint clean on all changed files; tsc introduces no new errors.
  • Vitest: landing-page.spec 13/13 (covers the landing change) + 16 features/shared specs exercising the refactored barrel graph — 138 tests green.
  • Field impact (CrUX/GSC p75) lags ~28 days; recheck early/mid June. Iterate fast with PSI lab in the meantime.

Intentionally out of scope

  • LCP image intrinsic dimensions in @ecency/render-helper: the img XSS whitelist excludes width/height/style, css:false strips inline styles, and sanitize runs last — reserving the box would require weakening the XSS policy or re-enabling author-controlled dimensions on every image. CLS already passes; not worth the blast radius.
  • Collapsing the post-page redirect hop: Next normalizes trailing slashes before custom redirects(), so the only reliable collapse (skipTrailingSlashRedirect) globally disables slash normalization (duplicate-content risk). The effective fix is infra-side (combine http→https + www→apex into a single edge 301); the 307→308 flip stays gated on GSC convergence.

Summary by CodeRabbit

  • Performance Improvements
    • Improved image preloading using responsive srcset/sizes for better LCP and mobile rendering.
    • Lazy-loaded non-critical UI (modals, dialogs) to reduce initial bundle and speed up first paint.
    • Deferred post-render enhancements with safer async setup, cancellation, and hardened error handling to avoid regressions.
  • Chores
    • Package release notes updated and helper package version bumped.

Review Change Stack

feruzm added 4 commits May 17, 2026 12:42
…chunk
setupPostEnhancements and its heavy deps (medium-zoom, embed/link
enhancers) were statically imported at module scope in
entry-page-body-viewer but only invoked post-hydration inside a
setTimeout. Load via dynamic import inside the effect so the subtree no
longer ships in the post page's first JS chunk. Adds a cancelled guard
for the extended async window. Behavior-preserving; cuts JS executed
during hydration (LCP render-delay / INP).
images.unoptimized=true means next/image does no optimization for these
static assets; it only added a client component and a hydration-gated
preload that pointed at the .png fallback while the <picture> renders
the .webp source. Plain server <img> (eager, high fetchpriority, intrinsic
width/height) ships in the initial HTML so the preload scanner finds the
webp immediately and hydration does less work. Footer logo next/image
(below the fold) left as-is.
The preload used a fixed catchPostImage(600,500) URL while the in-body
LCP <img> picks a candidate from srcset+sizes (100vw on mobile), so the
high-priority preload fetched a URL the page never renders. Add
imageSrcSet (buildSrcSet, same /p/<hash>?...&width=<w> format as the body
img) + imageSizes to the preload so it resolves to the exact rendition
the browser uses. href retained as the non-srcset fallback.
entry-menu's six modals (share, cross-post, edit-history, mute, promote,
translate) and entry-vote-btn's vote-slider dialog were statically
imported but only ever render behind a boolean flag set by a user
action. Convert to next/dynamic({ssr:false}) using the existing house
pattern (entry-votes/index.tsx) so they leave the post page's first
client chunk. Behavior-preserving: each still renders identically when
its flag flips. Footer controls / comments auto-load intentionally left
unchanged (higher-risk UX surface).
@coderabbitai

coderabbitaiBot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d0ad4b63-886a-46b4-9f32-35035f0a404a

📥 Commits

Reviewing files that changed from the base of the PR and between df10ab5 and cc34b9a.

⛔ Files ignored due to path filters (6)
  • packages/render-helper/dist/browser/index.js is excluded by !**/dist/**
  • packages/render-helper/dist/browser/index.js.map is excluded by !**/dist/**, !**/*.map
  • packages/render-helper/dist/node/index.cjs is excluded by !**/dist/**
  • packages/render-helper/dist/node/index.cjs.map is excluded by !**/dist/**, !**/*.map
  • packages/render-helper/dist/node/index.mjs is excluded by !**/dist/**
  • packages/render-helper/dist/node/index.mjs.map is excluded by !**/dist/**, !**/*.map
📒 Files selected for processing (2)
  • packages/render-helper/CHANGELOG.md
  • packages/render-helper/package.json

📝 Walkthrough

Walkthrough

This PR lazy-loads post-render enhancements and several modal dialogs, exports IMAGE_SIZES for shared responsive sizing, updates LCP image preload to include srcset/sizes, and switches landing page priority images to server-rendered HTML.

Changes

Performance Optimization via Code Splitting and Image Loading

Layer / File(s)Summary
IMAGE_SIZES shared constant export
packages/render-helper/src/methods/img.method.ts, packages/render-helper/src/index.ts
IMAGE_SIZES is extracted from a private constant to an exported constant and re-exported from the render-helper index as the single source of truth for responsive sizes.
LCP image preload with srcset
apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx
Entry page computes lcpImageSrcSet via buildSrcSet and wires imageSrcSet and imageSizes into the <link rel="preload" as="image"> so the preload matches rendered image renditions.
Priority image server-side rendering
apps/web/src/app/_components/landing-page/index.tsx
AssetPicture priority path now renders a <picture> with a WebP <source> and a plain server-rendered <img> fallback (loading="eager", fetchPriority="high", decoding="async") instead of next/image fallback.
Post-render enhancement async lazy-loading with cancellation
apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx
setupPostEnhancements is loaded via dynamic import() inside an async effect with a 100ms delay, DOM safety re-checks, a cancelled cleanup flag, and try/catch handling that preserves iOS/SecurityError logging.
Modal dialog lazy-loading via next/dynamic
apps/web/src/features/shared/entry-menu/index.tsx, apps/web/src/features/shared/entry-vote-btn/index.tsx
Interactive modals (share, cross-post, edit-history, mute, promote, translate) and the vote dialog are converted to next/dynamic imports with ssr: false, deferring their client chunks until needed.

Sequence Diagram(s)

(omitted — changes are focused and already captured in the layer summaries)

Possibly Related PRs

  • ecency/vision-next#755: Covers the LCP image preload update and render-helper API wiring (buildSrcSet + IMAGE_SIZES) for srcset/sizes consistency.
  • ecency/vision-next#618: Previously modified entry-page-body-viewer.tsx around setupPostEnhancements import; this PR refactors it further with async lazy-loading and cancellation.

Suggested Labels

patch

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit hops through code tonight,
Defers the chunks, makes preload right,
Images queuing just in time,
Lazy modals skip the prime.
Tiny hops, a faster flight — 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main objective of the PR: reducing JS during hydration and fixing LCP image preload for mobile Core Web Vitals, which aligns with the core changes across code-splitting, lazy-loading, and preload optimization.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/core-web-vitals

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 and usage tips.

@greptile-apps

greptile-appsBot commented May 17, 2026

Copy link
Copy Markdown

Greptile Summary

This PR targets mobile Core Web Vitals by reducing hydration-time JS and fixing the LCP image preload. Three independent optimisations are applied: (1) the heavy post-enhancement setup (medium-zoom, embed/link enhancers) is code-split via a dynamic import() guarded by a cancelled flag, (2) six entry-menu modals and the vote-slider dialog are converted to next/dynamic({ ssr:false }) since they only ever mount after user interaction, and (3) the LCP <link rel="preload"> now carries imageSrcSet/imageSizes built from the same buildSrcSet + IMAGE_SIZES constant the in-body <img> uses, ending the preload/render-URL mismatch.

  • entry-page-body-viewer.tsx: setupPostEnhancements and its deps are dynamically imported inside the existing 100 ms setTimeout; a cancelled flag correctly handles component unmount during the async load.
  • entry-menu/index.tsx & entry-vote-btn/index.tsx: Six modals and the vote-slider lazy-loaded via next/dynamic({ ssr:false }), matching the existing house pattern.
  • landing-page/index.tsx: Priority hero image switched from next/image to a plain <picture>/<img> with fetchPriority="high", eliminating a client-component boundary and a duplicate .png preload for an asset that is already unoptimized.
  • render-helper: IMAGE_SIZES exported as the single source of truth; dist rebuilt.

Confidence Score: 5/5

Safe to merge — all three optimisation paths are behaviour-preserving and the async guard in the body-viewer effect correctly handles component unmount during chunk loading.

The changes are narrowly scoped: static imports converted to dynamic ones, a cancelled flag added for the async code path, and the LCP preload wired to the same srcset/sizes source as the in-body image. No data-fetching, auth, or state-management logic was touched. The IMAGE_SIZES export makes the preload/renderer coupling explicit and compile-time safe.

No files require special attention — all changed files follow the existing house patterns for next/dynamic and the monorepo package rebuild workflow.

Important Files Changed

FilenameOverview
apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsxDynamic import of setupPostEnhancements with cancelled guard; cleanup flow is correct for both fast-unmount and normal paths.
apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsxLCP preload updated to use buildSrcSet + exported IMAGE_SIZES; imageSizes is now correctly guarded behind the srcset being non-empty.
apps/web/src/app/_components/landing-page/index.tsxHero image replaced with plain <picture>/<img> for priority assets; next/image retained only for the small logo icon — intentional split.
apps/web/src/features/shared/entry-menu/index.tsxSix modals converted to next/dynamic({ ssr:false }); existing component logic unchanged.
apps/web/src/features/shared/entry-vote-btn/index.tsxVote-slider dialog lazy-loaded via next/dynamic; AnimatePresence/motion.div wrapper remains statically bundled as expected.
packages/render-helper/src/methods/img.method.tsIMAGE_SIZES promoted to an exported constant; buildSrcSet and IMAGE_SIZES are now the single source of truth for both the in-body renderer and the page-level LCP preload.
packages/render-helper/src/index.tsRe-exports IMAGE_SIZES so the web app can import it from @ecency/render-helper.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Page SSR render] --> B[Emit LCP preload link\nimageSrcSet + imageSizes\nfetchpriority=high]
A --> C[Render HTML with\n#post-body]
B --> D[Browser discovers\nwebp/responsive rendition\nbefore JS runs]
C --> E[Hydration starts]
E --> F[EntryPageBodyViewer mounts]
F --> G[useEffect fires\n100ms setTimeout]
G --> H{cancelled?}
H -- yes --> I[No-op: component\nunmounted]
H -- no --> J[dynamic import\nsetupPostEnhancements]
J --> K{cancelled after\nimport?}
K -- yes --> I
K -- no --> L[setupPostEnhancements\nreturns cleanup fn]
L --> M[Post enhancements active]
E --> N[EntryMenu renders\nno modal chunks loaded]
N --> O{User opens kebab menu}
O -- action --> P[next/dynamic loads\nmodal chunk on demand]
P --> Q[Modal mounts]
E --> R[EntryVoteBtn renders\nno dialog chunk loaded]
R --> S{User clicks vote button}
S --> T[next/dynamic loads\nEntryVoteDialog]
T --> U[Dialog mounts]
Loading

Reviews (3): Last reviewed commit: "chore: apply changeset versioning for PR..." | Re-trigger Greptile

feruzm added 2 commits May 17, 2026 14:07
…n srcset
Review follow-up (PR #795):
- Export IMAGE_SIZES from @ecency/render-helper as the single source of
truth. It was a private const duplicated verbatim in page.tsx with only
a sync comment; the app now imports it, so any drift in the package
value fails app typecheck instead of silently re-introducing the
preload/srcset mismatch this change fixes.
- Only emit imageSizes when imageSrcSet is present (buildSrcSet can return
''); per the HTML spec imagesizes is meaningless without imagesrcset.
- Rebuilt render-helper dist (tracked in-repo).
@feruzmferuzm added the patch Bug fixes and patches (1.0.0 → 1.0.1) label May 17, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patchBug fixes and patches (1.0.0 → 1.0.1)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@feruzm