Skip to content

Commit a4f3aaa

Browse files
committed
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
1 parent 9d44d42 commit a4f3aaa

21 files changed

Lines changed: 149 additions & 106 deletions

File tree

apps/sim/app/(landing)/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost
3232
- **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.
3333
- **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.
3434
- **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`.
35-
- **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.
35+
- **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.
3636
- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero.
37+
- **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.
38+
- **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.
39+
- **Don't prefetch authenticated-app routes from an always-visible CTA.** `<Link>` 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).
3740

3841
## SEO
3942

apps/sim/app/(landing)/blog/[slug]/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
5656
sizes='(max-width: 768px) 100vw, 450px'
5757
priority
5858
itemProp='image'
59-
unoptimized
6059
/>
6160
</div>
6261
</div>
@@ -145,7 +144,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
145144
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
146145
className='object-cover'
147146
loading='lazy'
148-
unoptimized
149147
/>
150148
</div>
151149
<div className='flex flex-col gap-2'>

apps/sim/app/(landing)/blog/authors/[id]/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
9292
width={40}
9393
height={40}
9494
className='rounded-full'
95-
unoptimized
9695
/>
9796
) : null}
9897
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{author.name}</h1>
@@ -107,7 +106,6 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
107106
width={600}
108107
height={315}
109108
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
110-
unoptimized
111109
/>
112110
<div className='p-3'>
113111
<div className='mb-1 text-[var(--text-muted)] text-xs'>

apps/sim/app/(landing)/blog/components/blog-image.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export function BlogImage({ src, alt = '', width = 800, height = 450, className
2929
)}
3030
sizes='(max-width: 768px) 100vw, 800px'
3131
loading='lazy'
32-
unoptimized
3332
onClick={() => setIsLightboxOpen(true)}
3433
/>
3534
<Lightbox

apps/sim/app/(landing)/blog/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ export default async function BlogIndex({
138138
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
139139
className='object-cover'
140140
priority={index < 3}
141-
unoptimized
142141
/>
143142
</div>
144143
<div className='flex flex-col gap-2'>
@@ -203,7 +202,6 @@ export default async function BlogIndex({
203202
fill
204203
sizes='140px'
205204
className='object-cover'
206-
unoptimized
207205
/>
208206
</div>
209207
</Link>

apps/sim/app/(landing)/components/cta/cta.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function Cta() {
2828
Build your first agent today.
2929
</h2>
3030
<div className='flex items-center gap-1'>
31-
<ChipLink variant='primary' href='/signup'>
31+
<ChipLink variant='primary' href='/signup' prefetch={false}>
3232
Get started
3333
</ChipLink>
3434
<ChipLink href='/demo' className='border border-[var(--border-1)]'>

apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function HeroCta() {
2929
</ChipLink>
3030
<ChipLink
3131
href='/signup'
32+
prefetch={false}
3233
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
3334
>
3435
Sign up

apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ const CHROME_INTERIOR = { width: 1024, height: 721 } as const
5959
* chat|stage divider is covered by this region, so the divider users see is
6060
* the live `border-l`, appearing exactly as it does in the product.
6161
*
62-
* The chat pane stays `pointer-events-none` (decorative); the workflow pane is
63-
* interactive - blocks are draggable. Remounting the stage per cycle
64-
* (`key={cycleId}`) resets dragged positions and build state.
62+
* Both panes stay `pointer-events-none` (decorative, matching the hero's
63+
* `aria-hidden` frame) - blocks are static. Remounting the stage per cycle
64+
* (`key={cycleId}`) resets build state.
6565
*
6666
* Under `prefers-reduced-motion` the loop never starts: the finished exchange,
67-
* open stage, and fully-built workflow render statically (still draggable).
67+
* open stage, and fully-built workflow render statically.
6868
*/
6969
export function HeroPlatformLoop() {
7070
const regionRef = useRef<HTMLDivElement>(null)

apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx

Lines changed: 13 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type CSSProperties, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
3+
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { StageBlockCard } from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-block-card'
66
import {
@@ -22,43 +22,26 @@ interface HeroWorkflowStageProps {
2222
builtCount: number
2323
}
2424

25-
type Positions = Record<string, { x: number; y: number }>
26-
27-
const initialPositions = (): Positions =>
28-
Object.fromEntries(STAGE_BLOCKS.map((b) => [b.id, { x: b.x, y: b.y }]))
29-
3025
const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
3126

3227
/**
3328
* The hero window's live workflow canvas - the right-pane counterpart of the
3429
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
35-
* scale/fade entrances, edges stroke-draw once both endpoints exist), and every
36-
* block is DRAGGABLE: pointer-drag updates its position (scaled to design
37-
* space) and its edges follow live. The edge SVG is `overflow-visible` -
38-
* SVGs clip at their viewport by default, which cut the lines the moment a
39-
* dragged block left the design-canvas bounds while the HTML block cards
40-
* escaped freely. Positions reset when the parent remounts the stage for a
41-
* new loop pass (`key={cycleId}`).
30+
* scale/fade entrances, edges stroke-draw once both endpoints exist) at their
31+
* fixed positions. The edge SVG is `overflow-visible` - SVGs clip
32+
* at their viewport by default, which would cut the lines if a block ever sat
33+
* outside the design-canvas bounds.
34+
*
35+
* Decorative and `aria-hidden` (via the parent frame), so blocks are NOT
36+
* draggable - `pointer-events-none`, matching the rest of the hero animation.
4237
*
4338
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
4439
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
4540
* (top in / bottom out), matching the real editor's vertical layout.
4641
*/
4742
export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
48-
const [positions, setPositions] = useState<Positions>(initialPositions)
4943
const containerRef = useRef<HTMLDivElement>(null)
5044
const [scale, setScale] = useState(MAX_STAGE_SCALE)
51-
const dragRef = useRef<{
52-
id: string
53-
pointerId: number
54-
startX: number
55-
startY: number
56-
originX: number
57-
originY: number
58-
/** Total design-px -> visual-px factor for the dragged block (this stage's
59-
* fit scale x the platform loop's design-space scale), captured at grab. */
60-
visualScale: number
61-
} | null>(null)
6245

6346
// Fit the design canvas to the card: scale down when the pane narrows so the
6447
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
@@ -86,43 +69,6 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
8669
return () => ro.disconnect()
8770
}, [])
8871

89-
const onPointerDown = useCallback(
90-
(e: React.PointerEvent<HTMLDivElement>, id: string) => {
91-
if (dragRef.current) return
92-
const pos = positions[id]
93-
dragRef.current = {
94-
id,
95-
pointerId: e.pointerId,
96-
startX: e.clientX,
97-
startY: e.clientY,
98-
originX: pos.x,
99-
originY: pos.y,
100-
// Rendered width / design width = the block's total visual scale, all
101-
// ancestor transforms included - no need to thread each factor through.
102-
visualScale: e.currentTarget.getBoundingClientRect().width / BLOCK_WIDTH,
103-
}
104-
e.currentTarget.setPointerCapture(e.pointerId)
105-
},
106-
[positions]
107-
)
108-
109-
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
110-
const drag = dragRef.current
111-
if (!drag || e.pointerId !== drag.pointerId || drag.visualScale <= 0) return
112-
const dx = (e.clientX - drag.startX) / drag.visualScale
113-
const dy = (e.clientY - drag.startY) / drag.visualScale
114-
setPositions((prev) => ({
115-
...prev,
116-
[drag.id]: { x: drag.originX + dx, y: drag.originY + dy },
117-
}))
118-
}, [])
119-
120-
const onPointerEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
121-
const drag = dragRef.current
122-
if (!drag || e.pointerId !== drag.pointerId) return
123-
dragRef.current = null
124-
}, [])
125-
12672
const builtIds = useMemo(
12773
() => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)),
12874
[builtCount]
@@ -162,8 +108,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
162108
const target = STAGE_BLOCKS_BY_ID.get(to)
163109
if (!source || !target) return null
164110
const visible = builtIds.has(from) && builtIds.has(to)
165-
const s = handleAnchors(source, positions[from]).out
166-
const t = handleAnchors(target, positions[to]).in
111+
const s = handleAnchors(source, source).out
112+
const t = handleAnchors(target, target).in
167113
return (
168114
<path
169115
key={`${from}-${to}`}
@@ -181,20 +127,14 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
181127

182128
{STAGE_BLOCKS.map((block) => {
183129
const built = builtIds.has(block.id)
184-
const pos = positions[block.id]
185130
return (
186131
<div
187132
key={block.id}
188133
className={cn(
189-
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
190-
'transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
191-
built ? 'scale-100 opacity-100' : 'pointer-events-none scale-[0.94] opacity-0'
134+
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
135+
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
192136
)}
193-
style={{ left: pos.x, top: pos.y, width: BLOCK_WIDTH }}
194-
onPointerDown={(e) => onPointerDown(e, block.id)}
195-
onPointerMove={onPointerMove}
196-
onPointerUp={onPointerEnd}
197-
onPointerCancel={onPointerEnd}
137+
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
198138
>
199139
<StageBlockCard block={block} />
200140
</div>

apps/sim/app/(landing)/components/hero/components/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)