perf(landing): fix mobile LCP and cut the marketing pages from 6.3 MB to 0.6 MB - #419
Open
BSalaeddin wants to merge 3 commits into
Open
perf(landing): fix mobile LCP and cut the marketing pages from 6.3 MB to 0.6 MB#419BSalaeddin wants to merge 3 commits into
BSalaeddin wants to merge 3 commits into
Conversation
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
useupup.comscores 29 on PageSpeed Insights mobile (fleet SEO pull, 2026-09-11): LCP 17,687 ms, TBT 1,680 ms, TTI 26,398 ms, 143 requests, 6.3 MB. This PR makes the marketing pages paint their own copy before hydration and stops shipping four heavy, non-indexable surfaces to every visitor who never scrolls to them.Measured locally (production
next build+next start, Lighthouse 12.6.1,--preset=perf --form-factor=mobile --throttling-method=simulate, Playwright Chromium headless; median of 5 runs for/, 3 for the others). Raw JSON inupup-artifact/seo-2026-09-11/release/{lh-before-local,lh-after-local}/, full write-up inrelease/lh-summary.md./(homepage)<link rel=preload as=image>in the HTML/react/(representative framework page)(prod PSI reference for the sibling
/vue/page on 2026-09-11: performance 45, LCP 10,938.)/docs/getting-started/after: performance 73, LCP 2,359, TBT 1,235, 48 requests, 753 KiB. It has no local "before" — it is included as an untouched control page, see the TBT caveat.LCP element, before and after
Same element both times — the hero subtitle — with completely different timing:
div.relative > div.grid > div.flex > p.text-base, LCP 9,588 ms, phasesTTFB 4% / render delay 92%.div.relative > div.grid > div.flex > p.hero-lift, LCP 2,161 ms, and thelargest-contentful-paint-elementaudit now passes (score 1).Root causes
motion.div variants={containerVariants} initial="hidden", and the H1 ran throughBlurText, which wrapped every word in amotion.spanwith an inlineopacity: 0behind anIntersectionObserver. So the badge, H1, subtitle, CTAs and install command all shipped at opacity 0 and only became visible once framer had hydrated — 92% of a 9.6 s LCP was "render delay" on text that had been downloaded in the first 40 KB.HeroSessionscene renders eleven<img>tags with nowidth/heightand noloadingattribute; React 19 hoists those into<link rel="preload" as="image">at the very top of the document, in front of the CSS the hero copy needs.curl -s https://useupup.com/ | grep -c 'rel="preload" as="image"'→ 11; it is now 0.sdk.embedProjectran in a mount effect on/and/react/, pulling ~4.2 MB of staticblitz client + monaco for a section most visitors never reach, and@stackblitz/sdksat in the initial bundle.InteractiveExampleClientdragged@useupup/interactive-example(+@useupup/react, core,@mastra/client-js, pako) into/and every/[framework]/page, for a section below the fold.FeatureShowcase: more preloaded images, more DOM, more framer work, none of it indexable.gtag.jsloader were bothafterInteractive.Changes
Hero (
perf(landing): paint the hero copy from the server HTML…)globals.css:.hero-rise(opacity + transform) and.hero-lift(transform only), staggered by an inline--hero-delay, both switched off underprefers-reduced-motion: reduce. The hero copy now animates off the server HTML instead of waiting for hydration..hero-lift, never.hero-rise: an element atopacity: 0has not painted, so fading the LCP text in delays LCP by the animation's delay and duration. Measured on this build: subtitle LCP went 3,687 → 2,161 ms from that one change alone.BlurText.tsx→RisingWords.tsx: same visible words, now plain text nodes with a per-wordanimation-delay. No framer, noIntersectionObserver, nowill-change. (Its wrapper is also a<span>rather than the<p>it used to nest inside an<h1>.)HeroSession) isnext/dynamic({ ssr: false })behind theuseInViewgate it already had, with a placeholder that reserves its measured box (min-h-[740px], max-w 440 — measured 735 px at a 412 px viewport, 743 px at desktop).<img>inMockUploader/MockDriveBrowser/DragGhostgained intrinsicwidth/height(from a newsceneImageSize()manifest inscene-media.ts, read off the actual files) plusloading="lazy" decoding="async"— which is what stops React hoisting them into head preloads. They areobject-coverin absolutely-positioned boxes, so the attributes change no layout.whileHover/whileTapsurvive on the CTA buttons; nothing left has aninitialthat starts invisible.Deferred surfaces (
perf(landing): load the live demo, StackBlitz editor, feature scenes and analytics on demand)DeferredInteractiveExample.tsx:next/dynamic({ ssr: false })behind a plainIntersectionObserverwithrootMargin: 800px. Both/and/[framework]/render it instead ofInteractiveExampleClient. The lead distance is deliberate — the mounted demo is 1,564 px tall at a 412 px viewport against a 520 px placeholder, so the growth has to land off-screen.StackBlitzDemoSection: embeds only when the editor card is within 300 px of the viewport (useInView({ once: true, margin: '300px' })), and@stackblitz/sdkis now a dynamicimport()shared by the embed effect and the "Open in StackBlitz" button. Loading overlay, failure fallback, fullscreen re-embed flow and the#live-editoranchor are unchanged.FeatureShowcase: the row text stays server-rendered (it is indexable); eachVisualisnext/dynamic({ ssr: false })mounted by a second, earlier observer (margin: '600px',once) so the card's resize past itsmin-h-[320px]happens off-screen, while the existingamount: 0.2gate still controls whether the scene animates.strategy="lazyOnload". PostHog init timing is untouched — the e2e ingestion specs depend on it.apps/e2e-test/landing/thumbs-flow.spec.tsscrolls#demointo view before reaching for the Ask-AI panel, since the panel no longer exists at load.CI (
ci(landing): add a warn-only nightly mobile Lighthouse performance ratchet)apps/landing/lighthouserc.mobile.cjs:onlyCategories: ['performance'], mobile emulation,numberOfRuns: 5,aggregationMethod: 'median', port 4464 so it runs back to back with the existing config, reports to.lighthouseci-mobile-reports.lighthouserc.cjsis otherwise untouched apart from its header comment."lighthouse:mobile"script; one extra step in nightly's existingLighthousejob; the report-upload step'spathnow covers both directories. Warn-level assertions only — the score is genuinely noisy on a shared runner, andcontinue-on-erroris banned byscripts/ci/test-quality-guard.mjs, so a warn assertion is the only way to ratchet without risking a red night.0.66for/+/react/(medians 0.74 / 0.71),0.68for docs (median 0.73).lighthouserc.cjs) and CLAUDE.md's CI section are updated in the same commits, per CLAUDE.md's rule about process changes.No dependency changes (lockfile untouched), no copy changes, no route/metadata/canonical/JSON-LD/robots/sitemap changes, and no indexable text moved behind JS.
The TBT caveat
TBT is the one metric that moved the wrong way, and it is an artefact of the page no longer being network-starved:
bootup-timefell with it. Before, that work was smeared across 13 s of waiting on 133 requests and 6 MB, so few individual tasks crossed the 50 ms threshold TBT measures. After, the same hydration happens in one burst inside a 5.5 s load — exactly what TBT is designed to punish./docs/getting-started/is untouched by this PR apart from the<Script>strategies, has no demo, no StackBlitz and no scenes, and still measures TBT 1,235 ms on this box. Per-run TBT on/spanned 731 → 1,705 ms (performance 81 → 70) across the five runs while other work competed for the CPU.react-dom(1,217 ms of scripting, the single largest contributor) andposthog-js(87 KiB, 87% unused on this page).Next piece of work, deliberately not in this PR: the homepage's client-component surface (
HomepageFeaturesis ~1,000 lines of'use client'rendering indexable copy) and the PostHog bundle.Verification done locally
Everything below was run from
C:\wt-upup-perfon this branch.pnpm run lint(eslint . --max-warnings 0)pnpm run typecheckpnpm run testTest Files 8 passed (8) / Tests 67 passed (67)pnpm run test:quality399 test files + 5 workflows clean (0 exceptions pinned)pnpm run vocab:check1409 tracked files clean of 15 retired tokensprettier --checkon all 20 touched filesAll matched files use Prettier code style!playwright test --config playwright.landing.config.ts --project docs15 passed (10.2m), incl.playground CTA links into the homepage live editor(/#live-editor)146 passed, react74 passed, server32 passed / 6 skipped, each timegit push -u origin seo/mobile-performanceturbo run build --filter=@useupup/landing --forceBehaviour proved in a real browser against a running server (Playwright Chromium,
/):.upup-ie-ai-panelcount: 0 before scrolling, 1 after#demo.scrollIntoViewIfNeeded(), with the Ask-AI input visible — the paththumbs-flow.spec.tsnow takes.#live-editor iframecount: 0 before scrolling, 1 after — the StackBlitz gate embeds on approach, and the#live-editoranchor still resolves.rel="preload" as="image"count 0 (was 11), hero subtitle and H1 words present as plain text, noopacity:0anywhere in the hero.Two unrelated suites flaked under load during this run and were confirmed green in isolation, per CLAUDE.md's flake protocol:
@useupup/core tests/steps/heic-decode.test.tsandtests/strategies/multipart-part-body-materialization.test.ts(both use real 5 s/100 ms timing windows), and@useupup/interactive-example src/tests/InteractiveExample.test.tsx. All three passed in the commits' own hook runs.What CI covers
main.yml— prettier, test-quality guard, all-package units + coverage ratchets, typecheck, build, size-limit, prod audit.e2e.yml— affected-test routing will pick up theapps/landing+apps/e2e-testdiff.nightly.yml— the existing Lighthouse SEO/Best-Practices assertions are unchanged and still gate; the new mobile performance ratchet runs beside them at warn level, and its report lands in thenightly-lighthouse-reportsartifact alongside the existing one.Owner questions
lazyOnload. If nobody is reading the recordings it is free weight — say the word and it comes out entirely.gtag.jsis a second analytics pipeline on every page, and PostHog already carries the product analytics./cdn-cgi/scripts/.../email-decode.min.jsinto every page containing an email address — a request and a script we do not control and cannot defer from here. It is a dashboard toggle (Scrape Shield → Email Address Obfuscation) on theuseupup.comzone. Worth turning off?Not merging this myself.