Uh oh!
There was an error while loading. Please reload this page.
perf(cwv): cut hydration-time JS & fix LCP image preload (mobile Core Web Vitals) - #795
Conversation
…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).Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (6)
📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis 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. ChangesPerformance Optimization via Code Splitting and Image Loading
Sequence Diagram(s)(omitted — changes are focused and already captured in the layer summaries) Possibly Related PRs
Suggested Labels
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis 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
Confidence Score: 5/5Safe 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 No files require special attention — all changed files follow the existing house patterns for Important Files Changed
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]
Reviews (3): Last reviewed commit: "chore: apply changeset versioning for PR..." | Re-trigger Greptile |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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).
Uh oh!
There was an error while loading. Please reload this page.
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
setupPostEnhancementsand its heavy deps (medium-zoom, embed/link enhancers) were statically imported but only run post-hydration inside asetTimeout. Now dynamically imported inside that effect (with a cancelled guard for the async window). Behavior-preserving.next/dynamic({ ssr:false })using the existing house pattern (entry-votes/index.tsx). Footer controls / comments auto-load left unchanged (higher-risk UX surface).<img>for priority landing assets —images.unoptimized=truemeansnext/imagedid no optimization here; it only added a client component plus a hydration-gated preload pointing at the.pngfallback while the<picture>renders the.webp. Plain server<img>(eager, highfetchpriority, intrinsic dimensions) ships in the initial HTML and drops the wasted.pngfetch.catchPostImage(600,500)URL while the in-body LCP<img>selects fromsrcset+sizes(100vw on mobile), so the high-priority preload fetched a URL the page never renders. Now carriesimageSrcSet(buildSrcSet, same/p/<hash>?…&width=<w>format as the body img) +imageSizes.Validation
tscintroduces no new errors.landing-page.spec13/13 (covers the landing change) + 16features/sharedspecs exercising the refactored barrel graph — 138 tests green.Intentionally out of scope
@ecency/render-helper: theimgXSS whitelist excludeswidth/height/style,css:falsestrips 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.redirects(), so the only reliable collapse (skipTrailingSlashRedirect) globally disables slash normalization (duplicate-content risk). The effective fix is infra-side (combinehttp→https+www→apexinto a single edge 301); the 307→308 flip stays gated on GSC convergence.Summary by CodeRabbit