From 7bd734ceef9f2ed787d3c1cad09a8d0d2b40bb09 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 8 Jul 2026 17:49:35 -0700 Subject: [PATCH] fix(landing): fix Core Web Vitals regressions across public marketing pages (#5522) * fix(landing): fix Core Web Vitals regressions across public marketing pages - root layout unconditionally rendered next-runtime-env's PublicEnvScript, which calls unstable_noStore() and silently forced every route in the app dynamic - marketing pages never got static/ISR caching despite their own revalidate. Gated it to self-hosted only; hosted now uses a static, build-time equivalent (app/_shell/public-env-script.tsx) - removed real pointer-drag handlers from the hero's decorative workflow animation (was draggable despite being aria-hidden) - disabled dragging/panning on the (currently unmounted) landing-preview ReactFlow canvas so it's static-by-default if it's ever wired in - lazy-mount the Product Demo section's duplicate HeroVisual instance via next/dynamic + IntersectionObserver instead of loading it eagerly below the fold - disabled Next.js Link prefetch on always-in-viewport /signup and /login CTAs (navbar, hero, mobile nav) so their JS isn't fetched on every pageview regardless of whether the visitor clicks - removed `unoptimized` from local blog/integration images (including the priority LCP image on every blog post), letting next/image serve resized AVIF/WebP instead of full-size originals * fix(landing): address review findings on the CWV PR - revert prefetch={false} on below-fold CTAs (cta.tsx, enterprise.tsx) - contradicts the prefetch-on-approach rule this PR itself documents - restore unoptimized on avatarUrl and the MDX body-image renderer, both of which can legitimately hold external URLs outside next.config.ts's image remotePatterns allow-list - simplify handleAnchors to a single block argument now that positions are static (the second "live position" argument was always identical to the first after the drag-handler removal) - extract the near-duplicate IntersectionObserver lazy-mount logic shared by landing-preview-mount.tsx and product-demo-visual-mount.tsx into a single apps/sim/app/(landing)/hooks/use-lazy-mount.ts hook - import next-runtime-env's own exported PUBLIC_ENV_KEY constant instead of a hardcoded string literal, and match its case-insensitive NEXT_PUBLIC_ filter exactly, removing any drift risk between the two implementations - drop plain inline comments with no TSDoc home in favor of relying on the existing TSDoc/CLAUDE.md documentation * fix(landing): carry the unoptimized-image fix onto the shared content-*-page components The blog/library split (#5516) moved the blog post/index/author JSX into shared ContentPostPage/ContentIndexPage/ContentAuthorPage components while this branch was in flight, so the original unoptimized removal (verified local-only ogImage paths for both blog and library content) needs to land on those shared components instead of the old per-route JSX. * fix(landing): document ogImage's local-path expectation, fix CLAUDE.md structure doc - add a one-line comment on ContentFrontmatterSchema.ogImage documenting that it's rendered without unoptimized and expects a local path, matching the existing avatarUrl comment convention (a reviewer noted ogImage's schema is technically unconstrained and seo.ts has an http-prefix branch, though all current content is local) - add the new hooks/ folder to the (landing) CLAUDE.md structure diagram and name use-lazy-mount.ts directly in the lazy-mount rule * fix(landing): escape breakout in the static public-env script Greptile P1: a NEXT_PUBLIC_* value containing "" would close the inline script early and could inject markup/script into every hosted page. Escape "<" in the serialized JSON before interpolating it, matching the standard JSON-in-script-tag safeguard. --- apps/sim/app/(landing)/CLAUDE.md | 6 +- .../content-author-page.tsx | 1 - .../content-index-page/content-index-page.tsx | 2 - .../content-post-page/content-post-page.tsx | 2 - .../components/hero-cta/hero-cta.tsx | 1 + .../hero-platform-loop/hero-platform-loop.tsx | 8 +- .../hero-workflow-stage.tsx | 86 +++---------------- .../hero-platform-loop/stage-data.ts | 8 +- .../components/hero/components/index.ts | 1 - .../app/(landing)/components/hero/hero.tsx | 6 +- .../landing-preview-workflow.tsx | 7 +- .../landing-preview/landing-preview-mount.tsx | 43 ++-------- .../components/mobile-nav/mobile-nav.tsx | 3 +- .../(landing)/components/navbar/navbar.tsx | 6 +- .../product-demo-visual-mount/index.ts | 1 + .../product-demo-visual-mount.tsx | 38 ++++++++ .../components/product-demo/product-demo.tsx | 12 +-- .../sim/app/(landing)/hooks/use-lazy-mount.ts | 37 ++++++++ .../integrations/(shell)/[slug]/page.tsx | 1 - apps/sim/app/_shell/public-env-script.tsx | 36 ++++++++ apps/sim/app/layout.tsx | 5 +- apps/sim/lib/content/schema.ts | 2 +- 22 files changed, 170 insertions(+), 142 deletions(-) delete mode 100644 apps/sim/app/(landing)/components/hero/components/index.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/index.ts create mode 100644 apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/product-demo-visual-mount.tsx create mode 100644 apps/sim/app/(landing)/hooks/use-lazy-mount.ts create mode 100644 apps/sim/app/_shell/public-env-script.tsx diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md index 81d447fb09..6943cd7fdf 100644 --- a/apps/sim/app/(landing)/CLAUDE.md +++ b/apps/sim/app/(landing)/CLAUDE.md @@ -34,8 +34,11 @@ Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost - **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder. - **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift. - **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`. -- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time on the server or deferred to a tiny client island. +- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time or deferred to a client island. A `cookies()`/`headers()`/`unstable_noStore()` call anywhere in the tree - including the root `app/layout.tsx` - silently overrides every page's `revalidate` and forces the whole app dynamic. If a marketing page builds as `ƒ` instead of `○`/`●` (check `bun run build`'s route table), look upstream, not just at the page itself. - **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero. +- **Decorative canvases and animations are non-interactive.** A hand-built product-demo animation or embedded ReactFlow canvas is presentation only - no drag handlers, no `nodesDraggable`/`panOnDrag`/`elementsSelectable` on ReactFlow. A visitor should never be able to click or drag a decorative element. +- **Lazy-mount a heavy client island's second occurrence.** If the same animated component appears twice on a page, only the first (usually the hero) loads eagerly - the rest go through a small `'use client'` mount wrapper built on the shared `hooks/use-lazy-mount.ts` hook: `next/dynamic(..., { ssr: false })` gated by the hook's `IntersectionObserver`. See `components/product-demo/components/product-demo-visual-mount/` for the reference pattern, and `.claude/rules/sim-imports.md` for the barrel-cleanup step that must come with it. +- **Don't prefetch authenticated-app routes from an always-visible CTA.** `` prefetches its target route's JS once it's in the viewport - a navbar/hero CTA to `/signup` or `/login` is always in view, so it downloads that route's bundle on every pageview. Pass `prefetch={false}` there. Leave the default on CTAs that only enter the viewport on scroll (prefetch-on-approach is the desired behavior there). ## SEO @@ -72,6 +75,7 @@ Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI works ├── page.tsx # route entry: metadata + ├── landing.tsx # root composition:
section order ├── workflows/ # a platform route: page.tsx (metadata) + workflows.tsx (config + shell) +├── hooks/ # cross-page client hooks (useLazyMount, …) - bare files, no folder/barrel └── components/ ├── index.ts # top barrel ├── navbar/{navbar.tsx, index.ts, components//…} #