Skip to content

Commit 495460d

Browse files
authored
perf(landing): defer Features preview, memoize integration grid, trim dead weight (#5303)
* perf(landing): viewport-gate the Features workflow preview The preview is code-split (ssr:false) but was mounted eagerly, so its reactflow + framer-motion chunk (~85-100 KB gz) downloaded right after hydration despite sitting below the fold. Gate the dynamic mount on an IntersectionObserver (400px preload margin, loads once), reusing the existing dimension-stable aspect-[1116/615] placeholder so there's zero CLS. * perf(landing): memoize integration-grid filtering and the row Derive the category facets and a per-integration lowercased search index once from the (stable) integration list instead of rebuilding + re-lowercasing ~3.5k strings on every keystroke, and memo IntegrationRow so the ~220 rows don't all re-render per keystroke. Match semantics are identical (per-field includes). * chore(landing): drop orphaned assets, unused fonts, and dead component Remove unreferenced public/static/mothership.gif (1.5 MB) and public/landing/sim-mothership.webp (87 KB), the unused static Season font weights (only the variable woff2 is loaded), and the unused CtaChat component. * perf(landing): subscribe the hero resize listener once via a phase ref The resize effect re-subscribed on every phase change (~30x/loop); read the current phase from a ref so the listener is added once for the component's life. * perf(landing): load preview eagerly when IntersectionObserver is unavailable Graceful degradation for browsers/WebViews without IntersectionObserver — set inView immediately instead of leaving the preview stuck on its placeholder (addresses Greptile P2).
1 parent c1b84e4 commit 495460d

20 files changed

Lines changed: 96 additions & 62 deletions

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

Lines changed: 0 additions & 32 deletions
This file was deleted.

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -913,11 +913,16 @@ export function HeroVisual() {
913913
return () => cancelAnimationFrame(raf)
914914
}, [loaderPainting, paintFrame])
915915

916+
const phaseRef = useRef<Phase>(phase)
916917
useEffect(() => {
917-
const onResize = () => positionCursor(phase, true)
918+
phaseRef.current = phase
919+
}, [phase])
920+
921+
useEffect(() => {
922+
const onResize = () => positionCursor(phaseRef.current, true)
918923
window.addEventListener('resize', onResize)
919924
return () => window.removeEventListener('resize', onResize)
920-
}, [phase, positionCursor])
925+
}, [positionCursor])
921926

922927
useEffect(
923928
() => () => {

apps/sim/app/(landing)/components/landing-preview/landing-preview-mount.tsx

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

3+
import { useEffect, useRef, useState } from 'react'
34
import dynamic from 'next/dynamic'
45
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
56

7+
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
8+
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
9+
10+
/**
11+
* Load the preview chunk a little before it scrolls into view so it's ready by
12+
* the time the user reaches it, without paying for it on initial load.
13+
*/
14+
const PRELOAD_ROOT_MARGIN = '400px'
15+
616
/**
717
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
818
* island (framer-motion + reactflow). Isolated here so the sections that show it
919
* stay Server Components: only this leaf is `'use client'`.
1020
*
1121
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
12-
* the server-rendered HTML, and behind a dimension-stable placeholder sized to
13-
* the preview's exact `aspect-[1116/615]` footprint so there is zero layout
14-
* shift while it streams in. The placeholder fills with the canvas surface
15-
* (`--surface-1`) so there is no flash as the island mounts.
22+
* the server-rendered HTML, and **gated on viewport proximity**: the chunk only
23+
* downloads once an {@link IntersectionObserver} reports the mount is near the
24+
* viewport, so the below-the-fold previews don't pull the heavy bundle into the
25+
* initial homepage load. A dimension-stable placeholder (the preview's exact
26+
* `aspect-[1116/615]` footprint, filled with the canvas surface) holds the space
27+
* before and during load, so there is zero layout shift or flash.
1628
*/
1729
const LandingPreview = dynamic(
1830
() =>
@@ -21,7 +33,7 @@ const LandingPreview = dynamic(
2133
),
2234
{
2335
ssr: false,
24-
loading: () => <div className='aspect-[1116/615] w-full rounded bg-[var(--surface-1)]' />,
36+
loading: () => <div className={PLACEHOLDER_CLASS} />,
2537
}
2638
)
2739

@@ -35,5 +47,36 @@ interface LandingPreviewMountProps {
3547
}
3648

3749
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
38-
return <LandingPreview autoplay={autoplay} view={view} workflowId={workflowId} />
50+
const ref = useRef<HTMLDivElement>(null)
51+
const [inView, setInView] = useState(false)
52+
53+
useEffect(() => {
54+
if (inView) return
55+
// Graceful degradation: without IntersectionObserver support, load eagerly
56+
// rather than leave the preview stuck on its placeholder.
57+
if (typeof IntersectionObserver === 'undefined') {
58+
setInView(true)
59+
return
60+
}
61+
const el = ref.current
62+
if (!el) return
63+
const observer = new IntersectionObserver(
64+
([entry]) => {
65+
if (entry.isIntersecting) setInView(true)
66+
},
67+
{ rootMargin: PRELOAD_ROOT_MARGIN }
68+
)
69+
observer.observe(el)
70+
return () => observer.disconnect()
71+
}, [inView])
72+
73+
return (
74+
<div ref={ref}>
75+
{inView ? (
76+
<LandingPreview autoplay={autoplay} view={view} workflowId={workflowId} />
77+
) : (
78+
<div className={PLACEHOLDER_CLASS} />
79+
)}
80+
</div>
81+
)
3982
}

apps/sim/app/(landing)/integrations/components/integration-card.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ComponentType, SVGProps } from 'react'
2+
import { memo } from 'react'
23
import Link from 'next/link'
34
import type { Integration } from '@/lib/integrations'
45
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
@@ -46,7 +47,10 @@ export function IntegrationCard({ integration, IconComponent }: IntegrationItemP
4647
* Integration list row - matches blog remaining post pattern.
4748
* Each row followed by an h-px divider.
4849
*/
49-
export function IntegrationRow({ integration, IconComponent }: IntegrationItemProps) {
50+
export const IntegrationRow = memo(function IntegrationRow({
51+
integration,
52+
IconComponent,
53+
}: IntegrationItemProps) {
5054
const { slug, name, description, bgColor } = integration
5155

5256
return (
@@ -80,4 +84,4 @@ export function IntegrationRow({ integration, IconComponent }: IntegrationItemPr
8084
<div className='h-px w-full bg-[var(--border)]' />
8185
</>
8286
)
83-
}
87+
})

apps/sim/app/(landing)/integrations/components/integration-grid.tsx

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

3+
import { useMemo } from 'react'
34
import { ChipInput, Search } from '@sim/emcn'
45
import { debounce, useQueryStates } from 'nuqs'
56
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
@@ -28,29 +29,42 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
2829
)
2930
const activeCategory = category || null
3031

31-
const counts = new Map<string, number>()
32-
for (const i of integrations) {
33-
if (i.integrationType) {
34-
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
32+
// Category facets and a per-integration lowercased search index, derived once
33+
// from the (stable) integration list instead of rebuilt on every keystroke.
34+
// The index keeps each searchable field as its own entry so matching stays
35+
// identical to a per-field `includes` (no cross-field boundary matches).
36+
const { availableCategories, searchIndex } = useMemo(() => {
37+
const counts = new Map<string, number>()
38+
const searchIndex = new Map<string, string[]>()
39+
for (const i of integrations) {
40+
if (i.integrationType) {
41+
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
42+
}
43+
searchIndex.set(i.type, [
44+
i.name.toLowerCase(),
45+
i.description.toLowerCase(),
46+
...i.operations.flatMap((op) => [op.name.toLowerCase(), op.description.toLowerCase()]),
47+
...i.triggers.map((t) => t.name.toLowerCase()),
48+
])
3549
}
36-
}
37-
const availableCategories = Array.from(counts.entries())
38-
.sort((a, b) => b[1] - a[1])
39-
.map(([key]) => key)
50+
return {
51+
availableCategories: Array.from(counts.entries())
52+
.sort((a, b) => b[1] - a[1])
53+
.map(([key]) => key),
54+
searchIndex,
55+
}
56+
}, [integrations])
4057

4158
const q = query.trim().toLowerCase()
42-
const filtered = integrations.filter((i) => {
43-
if (activeCategory && i.integrationType !== activeCategory) return false
44-
if (!q) return true
45-
return (
46-
i.name.toLowerCase().includes(q) ||
47-
i.description.toLowerCase().includes(q) ||
48-
i.operations.some(
49-
(op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
50-
) ||
51-
i.triggers.some((t) => t.name.toLowerCase().includes(q))
52-
)
53-
})
59+
const filtered = useMemo(
60+
() =>
61+
integrations.filter((i) => {
62+
if (activeCategory && i.integrationType !== activeCategory) return false
63+
if (!q) return true
64+
return searchIndex.get(i.type)?.some((field) => field.includes(q)) ?? false
65+
}),
66+
[integrations, searchIndex, q, activeCategory]
67+
)
5468

5569
return (
5670
<div>
-74.5 KB
Binary file not shown.
-53.8 KB
Binary file not shown.
-70 KB
Binary file not shown.
-50.8 KB
Binary file not shown.
-68.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)