From 51950fe025b945579cb15b971b8864c4e79779ad Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 8 Jul 2026 16:27:20 -0700 Subject: [PATCH 1/5] 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 --- apps/sim/app/(landing)/CLAUDE.md | 5 +- .../content-image/content-image.tsx | 1 - apps/sim/app/(landing)/components/cta/cta.tsx | 2 +- .../components/hero-cta/hero-cta.tsx | 1 + .../hero-platform-loop/hero-platform-loop.tsx | 8 +- .../hero-workflow-stage.tsx | 86 +++---------------- .../components/hero/components/index.ts | 1 - .../app/(landing)/components/hero/hero.tsx | 6 +- .../landing-preview-workflow.tsx | 7 +- .../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 | 66 ++++++++++++++ .../components/product-demo/product-demo.tsx | 12 +-- .../app/(landing)/enterprise/enterprise.tsx | 2 +- .../integrations/(shell)/[slug]/page.tsx | 1 - apps/sim/app/_shell/public-env-script.tsx | 32 +++++++ apps/sim/app/layout.tsx | 9 +- 18 files changed, 149 insertions(+), 100 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/_shell/public-env-script.tsx diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md index 81d447fb09d..edc420ef1bd 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: `next/dynamic(..., { ssr: false })` gated by an `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 diff --git a/apps/sim/app/(landing)/components/content-image/content-image.tsx b/apps/sim/app/(landing)/components/content-image/content-image.tsx index 001430c7a4a..0a830da6871 100644 --- a/apps/sim/app/(landing)/components/content-image/content-image.tsx +++ b/apps/sim/app/(landing)/components/content-image/content-image.tsx @@ -39,7 +39,6 @@ export function ContentImage({ )} sizes='(max-width: 768px) 100vw, 800px' loading='lazy' - unoptimized onClick={() => setIsLightboxOpen(true)} />
- + Get started diff --git a/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx b/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx index 4503e7de9b0..93a4dee3d6e 100644 --- a/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx +++ b/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx @@ -29,6 +29,7 @@ export function HeroCta() { Sign up diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.tsx index c154d1beb08..eb3d8b1c72a 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.tsx @@ -59,12 +59,12 @@ const CHROME_INTERIOR = { width: 1024, height: 721 } as const * chat|stage divider is covered by this region, so the divider users see is * the live `border-l`, appearing exactly as it does in the product. * - * The chat pane stays `pointer-events-none` (decorative); the workflow pane is - * interactive - blocks are draggable. Remounting the stage per cycle - * (`key={cycleId}`) resets dragged positions and build state. + * Both panes stay `pointer-events-none` (decorative, matching the hero's + * `aria-hidden` frame) - blocks are static. Remounting the stage per cycle + * (`key={cycleId}`) resets build state. * * Under `prefers-reduced-motion` the loop never starts: the finished exchange, - * open stage, and fully-built workflow render statically (still draggable). + * open stage, and fully-built workflow render statically. */ export function HeroPlatformLoop() { const regionRef = useRef(null) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx index cef1ead45e2..ea8b6deb9f3 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx @@ -1,6 +1,6 @@ 'use client' -import { type CSSProperties, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card' import { @@ -22,43 +22,26 @@ interface HeroWorkflowStageProps { builtCount: number } -type Positions = Record - -const initialPositions = (): Positions => - Object.fromEntries(STAGE_BLOCKS.map((b) => [b.id, { x: b.x, y: b.y }])) - const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b])) /** * The hero window's live workflow canvas - the right-pane counterpart of the * chat loop. Blocks pop in one by one as `builtCount` advances (staggered - * scale/fade entrances, edges stroke-draw once both endpoints exist), and every - * block is DRAGGABLE: pointer-drag updates its position (scaled to design - * space) and its edges follow live. The edge SVG is `overflow-visible` - - * SVGs clip at their viewport by default, which cut the lines the moment a - * dragged block left the design-canvas bounds while the HTML block cards - * escaped freely. Positions reset when the parent remounts the stage for a - * new loop pass (`key={cycleId}`). + * scale/fade entrances, edges stroke-draw once both endpoints exist) at their + * fixed positions. The edge SVG is `overflow-visible` - SVGs clip + * at their viewport by default, which would cut the lines if a block ever sat + * outside the design-canvas bounds. + * + * Decorative and `aria-hidden` (via the parent frame), so blocks are NOT + * draggable - `pointer-events-none`, matching the rest of the hero animation. * * Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful * icon-tile + rows card body) in a card shell with vertical-flow handle nubs * (top in / bottom out), matching the real editor's vertical layout. */ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) { - const [positions, setPositions] = useState(initialPositions) const containerRef = useRef(null) const [scale, setScale] = useState(MAX_STAGE_SCALE) - const dragRef = useRef<{ - id: string - pointerId: number - startX: number - startY: number - originX: number - originY: number - /** Total design-px -> visual-px factor for the dragged block (this stage's - * fit scale x the platform loop's design-space scale), captured at grab. */ - visualScale: number - } | null>(null) // Fit the design canvas to the card: scale down when the pane narrows so the // branch blocks never clip, capped at the full-width scale. Measures LAYOUT @@ -86,43 +69,6 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) { return () => ro.disconnect() }, []) - const onPointerDown = useCallback( - (e: React.PointerEvent, id: string) => { - if (dragRef.current) return - const pos = positions[id] - dragRef.current = { - id, - pointerId: e.pointerId, - startX: e.clientX, - startY: e.clientY, - originX: pos.x, - originY: pos.y, - // Rendered width / design width = the block's total visual scale, all - // ancestor transforms included - no need to thread each factor through. - visualScale: e.currentTarget.getBoundingClientRect().width / BLOCK_WIDTH, - } - e.currentTarget.setPointerCapture(e.pointerId) - }, - [positions] - ) - - const onPointerMove = useCallback((e: React.PointerEvent) => { - const drag = dragRef.current - if (!drag || e.pointerId !== drag.pointerId || drag.visualScale <= 0) return - const dx = (e.clientX - drag.startX) / drag.visualScale - const dy = (e.clientY - drag.startY) / drag.visualScale - setPositions((prev) => ({ - ...prev, - [drag.id]: { x: drag.originX + dx, y: drag.originY + dy }, - })) - }, []) - - const onPointerEnd = useCallback((e: React.PointerEvent) => { - const drag = dragRef.current - if (!drag || e.pointerId !== drag.pointerId) return - dragRef.current = null - }, []) - const builtIds = useMemo( () => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)), [builtCount] @@ -162,8 +108,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) { const target = STAGE_BLOCKS_BY_ID.get(to) if (!source || !target) return null const visible = builtIds.has(from) && builtIds.has(to) - const s = handleAnchors(source, positions[from]).out - const t = handleAnchors(target, positions[to]).in + const s = handleAnchors(source, source).out + const t = handleAnchors(target, target).in return ( { const built = builtIds.has(block.id) - const pos = positions[block.id] return (
onPointerDown(e, block.id)} - onPointerMove={onPointerMove} - onPointerUp={onPointerEnd} - onPointerCancel={onPointerEnd} + style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }} >
diff --git a/apps/sim/app/(landing)/components/hero/components/index.ts b/apps/sim/app/(landing)/components/hero/components/index.ts deleted file mode 100644 index 8085f6b062f..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { HeroVisual } from './hero-visual/hero-visual' diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index 0b690055a45..14fb4a10f26 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -40,9 +40,9 @@ import { TrustedBy } from '@/app/(landing)/components/trusted-by' * SIDEBAR * remains visible from the shot: the {@link HeroPlatformLoop} island overlays * the container interior (full-width chat that stages the workflow pane in, - * replaying the conversation with the goo ThinkingLoader; blocks stay - * draggable), inset a hair INSIDE the shot's own baked outlines so the visible - * chrome is the real UI's pixels - never re-drawn. + * replaying the conversation with the goo ThinkingLoader), inset a hair INSIDE + * the shot's own baked outlines so the visible chrome is the real UI's pixels + * - never re-drawn. * The frame is `1300/720` and the window `1080/620` at `83.08%` width, centered * - matching cursor.com's hero media proportions, with backdrop showing on all * four sides. Decorative, `aria-hidden`; the `--surface-3` fill remains as the diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow.tsx index b1484c909db..0584ccfb944 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow.tsx @@ -117,13 +117,13 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps) edgeTypes={EDGE_TYPES} defaultEdgeOptions={{ type: 'previewEdge' }} elementsSelectable={false} - nodesDraggable + nodesDraggable={false} nodesConnectable={false} zoomOnScroll={false} zoomOnDoubleClick={false} panOnScroll={false} zoomOnPinch={false} - panOnDrag + panOnDrag={false} preventScrolling={false} autoPanOnNodeDrag={false} proOptions={PRO_OPTIONS} @@ -136,7 +136,8 @@ function PreviewFlow({ workflow, animate = false }: LandingPreviewWorkflowProps) } /** - * Lightweight ReactFlow canvas displaying an interactive workflow preview. + * Lightweight ReactFlow canvas displaying a static workflow preview - + * decorative, so panning, dragging, zooming, and selecting are all disabled. * The key on workflow.id forces a clean remount on switch - instant fitView, * no timers, no flicker. */ diff --git a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx index f0750f04fe4..96e5a74d7ca 100644 --- a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx @@ -67,7 +67,7 @@ export function MobileNav({ stars }: MobileNavProps) { return (
- + Sign up
- Log in + + Log in + Contact sales - + Sign up
diff --git a/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/index.ts b/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/index.ts new file mode 100644 index 00000000000..53304d8970f --- /dev/null +++ b/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/index.ts @@ -0,0 +1 @@ +export { ProductDemoVisualMount } from './product-demo-visual-mount' diff --git a/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/product-demo-visual-mount.tsx b/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/product-demo-visual-mount.tsx new file mode 100644 index 00000000000..16dd6b5dcd4 --- /dev/null +++ b/apps/sim/app/(landing)/components/product-demo/components/product-demo-visual-mount/product-demo-visual-mount.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import dynamic from 'next/dynamic' + +/** + * Load the visual a little before it scrolls into view so it's ready by the + * time the user reaches it, without paying for it on initial load. + */ +const PRELOAD_ROOT_MARGIN = '400px' + +/** + * `ssr: false` so the animation's client bundle never ships in the + * server-rendered HTML for a section that starts below the fold. + */ +const HeroVisual = dynamic( + () => + import('@/app/(landing)/components/hero/components/hero-visual/hero-visual').then( + (mod) => mod.HeroVisual + ), + { ssr: false } +) + +/** + * Client mount for the {@link HeroVisual} island reused in the Product Demo + * section. Isolated here so `ProductDemo` stays a Server Component: only this + * leaf is `'use client'`. + * + * Gated on viewport proximity via {@link IntersectionObserver} so the below- + * the-fold section doesn't pull the animation's JS - or start its + * `requestAnimationFrame` loop - into the initial homepage load, mirroring + * {@link LandingPreviewMount}'s pattern. The parent frame + * (`product-demo.tsx`) already reserves fixed pixel dimensions for this slot, + * so there is no placeholder to size - an empty div holds the spot with zero + * layout shift. + */ +export function ProductDemoVisualMount() { + const ref = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + if (inView) return + // Graceful degradation: without IntersectionObserver support, load eagerly + // rather than leave the section stuck on its placeholder. + if (typeof IntersectionObserver === 'undefined') { + setInView(true) + return + } + const el = ref.current + if (!el) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) setInView(true) + }, + { rootMargin: PRELOAD_ROOT_MARGIN } + ) + observer.observe(el) + return () => observer.disconnect() + }, [inView]) + + return ( +
+ {inView && } +
+ ) +} diff --git a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx index 4181163ec2f..5515f151c81 100644 --- a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx +++ b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx @@ -1,5 +1,5 @@ -import { HeroVisual } from '@/app/(landing)/components/hero/components/hero-visual/hero-visual' import { LoaderMarks } from '@/app/(landing)/components/product-demo/components/loader-marks' +import { ProductDemoVisualMount } from '@/app/(landing)/components/product-demo/components/product-demo-visual-mount' /** * Product-demo band - the looping animated platform walkthrough, promoted out of @@ -12,8 +12,10 @@ import { LoaderMarks } from '@/app/(landing)/components/product-demo/components/ * copy column on the right. Below `lg` it stacks media-over-copy like the * cards do. * - * The visual is the same {@link HeroVisual} client island the hero used to - * host (decorative, `aria-hidden`). The section carries the shared + * The visual is the same `HeroVisual` client island the hero used to host + * (decorative, `aria-hidden`), lazy-mounted via {@link ProductDemoVisualMount} + * so its bundle and animation loop only load once this below-the-fold section + * nears the viewport. The section carries the shared * `px-20 max-lg:px-8 max-sm:px-5` gutter and the `max-w-[1460px]` cap so the * card aligns with the hero's media frame above and the feature cards below. */ @@ -29,9 +31,7 @@ export function ProductDemo() { aria-hidden='true' className='relative h-[650px] overflow-hidden rounded-[4px] bg-[var(--surface-5)] max-sm:h-[280px] max-lg:h-[360px]' > -
- -
+
diff --git a/apps/sim/app/(landing)/enterprise/enterprise.tsx b/apps/sim/app/(landing)/enterprise/enterprise.tsx index df62e6e8d78..d1cc70c29d2 100644 --- a/apps/sim/app/(landing)/enterprise/enterprise.tsx +++ b/apps/sim/app/(landing)/enterprise/enterprise.tsx @@ -176,7 +176,7 @@ export default function EnterprisePage() { Build enterprise AI agents in Sim
- + Get started diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx index d0cdedffa71..172e47fa31e 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx @@ -914,7 +914,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl width={56} height={56} className='shrink-0 rounded-xl' - unoptimized />