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 </script> breakout in the static public-env script

Greptile P1: a NEXT_PUBLIC_* value containing "</script>" 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.
This commit is contained in:
Waleed
2026-07-08 17:49:35 -07:00
committed by GitHub
parent 1ee98a61f2
commit 7bd734ceef
22 changed files with 170 additions and 142 deletions
+5 -1
View File
@@ -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.** `<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).
## SEO
@@ -72,6 +75,7 @@ Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI works
├── page.tsx # route entry: metadata + <Landing />
├── landing.tsx # root composition: <main> 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/<chip>/…} # <header><nav>: wordmark, dropdowns, stars, auth chips
@@ -55,7 +55,6 @@ export function ContentAuthorPage({
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
@@ -96,7 +96,6 @@ export function ContentIndexPage({
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
@@ -158,7 +157,6 @@ export function ContentIndexPage({
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
@@ -53,7 +53,6 @@ export function ContentPostPage({
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
@@ -142,7 +141,6 @@ export function ContentPostPage({
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
@@ -29,6 +29,7 @@ export function HeroCta() {
</ChipLink>
<ChipLink
href='/signup'
prefetch={false}
className={cn(CTA_SIZE, 'border border-[var(--border-1)] max-sm:justify-center')}
>
Sign up
@@ -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<HTMLDivElement>(null)
@@ -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<string, { x: number; y: number }>
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<Positions>(initialPositions)
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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).out
const t = handleAnchors(target).in
return (
<path
key={`${from}-${to}`}
@@ -181,20 +127,14 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
{STAGE_BLOCKS.map((block) => {
const built = builtIds.has(block.id)
const pos = positions[block.id]
return (
<div
key={block.id}
className={cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
'transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
built ? 'scale-100 opacity-100' : 'pointer-events-none scale-[0.94] opacity-0'
'pointer-events-none absolute transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
built ? 'scale-100 opacity-100' : 'scale-[0.94] opacity-0'
)}
style={{ left: pos.x, top: pos.y, width: BLOCK_WIDTH }}
onPointerDown={(e) => onPointerDown(e, block.id)}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }}
>
<StageBlockCard block={block} />
</div>
@@ -120,10 +120,10 @@ export function verticalSmoothStep(sx: number, sy: number, tx: number, ty: numbe
].join(' ')
}
/** Handle anchor points for a block at a live position. */
export function handleAnchors(block: BlockDef, pos: { x: number; y: number }) {
/** Handle anchor points for a block at its fixed position. */
export function handleAnchors(block: BlockDef) {
return {
out: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y + blockHeight(block) },
in: { x: pos.x + BLOCK_WIDTH / 2, y: pos.y },
out: { x: block.x + BLOCK_WIDTH / 2, y: block.y + blockHeight(block) },
in: { x: block.x + BLOCK_WIDTH / 2, y: block.y },
}
}
@@ -1 +0,0 @@
export { HeroVisual } from './hero-visual/hero-visual'
@@ -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
@@ -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.
*/
@@ -1,30 +1,24 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
/** Dimension-stable placeholder sized to the preview's exact footprint (zero CLS). */
const PLACEHOLDER_CLASS = 'aspect-[1116/615] w-full rounded bg-[var(--surface-1)]'
/**
* Load the preview chunk 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'
/**
* Client mount for the {@link LandingPreview} - the heavy, animated workspace
* island (framer-motion + reactflow). Isolated here so the sections that show it
* stay Server Components: only this leaf is `'use client'`.
*
* Loaded with `ssr: false` so the framer-motion/reactflow bundle never ships in
* the server-rendered HTML, and **gated on viewport proximity**: the chunk only
* downloads once an {@link IntersectionObserver} reports the mount is near the
* viewport, so the below-the-fold previews don't pull the heavy bundle into the
* initial homepage load. A dimension-stable placeholder (the preview's exact
* `aspect-[1116/615]` footprint, filled with the canvas surface) holds the space
* before and during load, so there is zero layout shift or flash.
* the server-rendered HTML, and gated on viewport proximity via
* {@link useLazyMount} so the below-the-fold previews don't pull the heavy
* bundle into the initial homepage load. A dimension-stable placeholder (the
* preview's exact `aspect-[1116/615]` footprint, filled with the canvas
* surface) holds the space before and during load, so there is zero layout
* shift or flash.
*/
const LandingPreview = dynamic(
() =>
@@ -47,28 +41,7 @@ interface LandingPreviewMountProps {
}
export function LandingPreviewMount({ autoplay, view, workflowId }: LandingPreviewMountProps) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
if (inView) return
// Graceful degradation: without IntersectionObserver support, load eagerly
// rather than leave the preview 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])
const { ref, inView } = useLazyMount('400px')
return (
<div ref={ref}>
@@ -67,7 +67,7 @@ export function MobileNav({ stars }: MobileNavProps) {
return (
<div className='ml-auto flex items-center gap-2 lg:hidden'>
<ChipLink variant='primary' href='/signup'>
<ChipLink variant='primary' href='/signup' prefetch={false}>
Sign up
</ChipLink>
<button
@@ -154,6 +154,7 @@ export function MobileNav({ stars }: MobileNavProps) {
href='/login'
fullWidth
flush
prefetch={false}
className='h-[40px] justify-center border border-[var(--border-1)] [&>span]:flex-none'
onClick={() => setOpen(false)}
>
@@ -81,11 +81,13 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
</div>
<div className='ml-auto hidden items-center gap-1 lg:flex'>
<ChipLink href='/login'>Log in</ChipLink>
<ChipLink href='/login' prefetch={false}>
Log in
</ChipLink>
<ChipLink href='/demo' className='border border-[var(--border-1)]'>
Contact sales
</ChipLink>
<ChipLink variant='primary' href='/signup'>
<ChipLink variant='primary' href='/signup' prefetch={false}>
Sign up
</ChipLink>
</div>
@@ -0,0 +1 @@
export { ProductDemoVisualMount } from './product-demo-visual-mount'
@@ -0,0 +1,38 @@
'use client'
import dynamic from 'next/dynamic'
import { useLazyMount } from '@/app/(landing)/hooks/use-lazy-mount'
/**
* `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 useLazyMount} so the below-the-fold
* section doesn't pull the animation's JS - or start its
* `requestAnimationFrame` loop - into the initial homepage load. 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, inView } = useLazyMount('400px')
return (
<div ref={ref} className='absolute inset-0'>
{inView && <HeroVisual />}
</div>
)
}
@@ -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]'
>
<div className='absolute inset-0'>
<HeroVisual />
</div>
<ProductDemoVisualMount />
</div>
<div className='flex flex-col justify-center pr-4 max-lg:pr-0 max-lg:pb-2'>
@@ -0,0 +1,37 @@
'use client'
import { useEffect, useRef, useState } from 'react'
/**
* Gates mounting a heavy client island on viewport proximity via
* {@link IntersectionObserver}, so its bundle (and any `requestAnimationFrame`
* loop) only loads once the section nears the viewport instead of on initial
* page load. Falls back to an eager mount when `IntersectionObserver` is
* unavailable, so the section never gets stuck unmounted.
*
* Pair with `next/dynamic(..., { ssr: false })` for the mounted component.
*/
export function useLazyMount(rootMargin: string) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
if (inView) return
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 }
)
observer.observe(el)
return () => observer.disconnect()
}, [inView, rootMargin])
return { ref, inView }
}
@@ -914,7 +914,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
width={56}
height={56}
className='shrink-0 rounded-xl'
unoptimized
/>
<div className='flex items-center gap-2'>
<span className='h-px w-5 bg-[var(--border-1)]' aria-hidden='true' />
+36
View File
@@ -0,0 +1,36 @@
import { PUBLIC_ENV_KEY } from 'next-runtime-env'
/**
* `NEXT_PUBLIC_*` values, captured once at module load (build time / server
* start) rather than read per-request - correct on the hosted deployment,
* where a build's env never changes between requests. Filter matches
* `next-runtime-env`'s own `getPublicEnv()` exactly.
*/
const HOSTED_PUBLIC_ENV = Object.fromEntries(
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
)
/**
* Static, build-time equivalent of `next-runtime-env`'s `<PublicEnvScript>`
* for the hosted deployment. It populates `window[PUBLIC_ENV_KEY]` with the
* exact same shape `getEnv()` (`lib/core/config/env.ts`) reads client-side,
* but without `next-runtime-env`'s unconditional `unstable_noStore()` call -
* that call opts the entire app into dynamic rendering, which only pays off
* for self-hosted Docker images that re-inject env per deploy without a
* rebuild. On hosted, env is fixed per build, so this is safe to render
* statically alongside the marketing pages' `revalidate`.
*
* Escapes `<` in the serialized JSON so an env value containing `</script>`
* can't close this tag early and inject markup into every hosted page.
*/
export function PublicEnvScript() {
const serialized = JSON.stringify(HOSTED_PUBLIC_ENV).replace(/</g, '\\u003c')
return (
<script
id='public-env'
dangerouslySetInnerHTML={{
__html: `window['${PUBLIC_ENV_KEY}'] = ${serialized}`,
}}
/>
)
}
+3 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata, Viewport } from 'next'
import Script from 'next/script'
import { PublicEnvScript } from 'next-runtime-env'
import { PublicEnvScript as RuntimePublicEnvScript } from 'next-runtime-env'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { BrandedLayout } from '@/components/branded-layout'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
@@ -12,6 +12,7 @@ import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
@@ -227,7 +228,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
</>
)}
<PublicEnvScript />
{isHosted ? <PublicEnvScript /> : <RuntimePublicEnvScript />}
</head>
<body className={`${season.variable} font-season`} suppressHydrationWarning>
{/* Google Tag Manager (noscript) — hosted only */}
+1 -1
View File
@@ -27,7 +27,7 @@ export const ContentFrontmatterSchema = z
authors: z.array(z.string()).min(1),
readingTime: z.number().int().positive().optional(),
tags: z.array(z.string()).default([]),
ogImage: z.string().min(1),
ogImage: z.string().min(1), // local path (e.g. /blog/<slug>/cover.jpg) - rendered via next/image without `unoptimized`
ogAlt: z.string().optional(),
about: z.array(z.string()).optional(),
timeRequired: z.string().optional(),