diff --git a/apps/web/perf-baseline.json b/apps/web/perf-baseline.json index 37d406ce7..cde5086c1 100644 --- a/apps/web/perf-baseline.json +++ b/apps/web/perf-baseline.json @@ -1,8 +1,8 @@ { "updatedAt": "2026-08-03T20:35:00.443Z", "metrics": { - "mainJsGzip": 258, - "mainCssGzip": 34923, + "mainJsGzip": 423231, + "mainCssGzip": 36274, "stackBuilderJsGzip": 77059, "largestJsGzip": 665363, "totalJsGzip": 1978679 @@ -13,5 +13,19 @@ "stackBuilderJsGzip": 12288, "largestJsGzip": 20480, "totalJsGzip": 81920 + }, + "entryMeasurementVersion": 2, + "entryMeasurementMigration": { + "date": "2026-09-05T14:34:40.633Z", + "source": "origin/main at 8ea3b9b70 before performance changes, built with Vite manifest enabled", + "previousMainJsGzip": 258, + "assets": { + "js": [ + "assets/index-gRR60bg9.js" + ], + "css": [ + "assets/index-DOVWU8Xw.css" + ] + } } } diff --git a/apps/web/scripts/check-performance-budget.mjs b/apps/web/scripts/check-performance-budget.mjs index fb3f301e5..de15d81f9 100644 --- a/apps/web/scripts/check-performance-budget.mjs +++ b/apps/web/scripts/check-performance-budget.mjs @@ -1,10 +1,12 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { gzipSync } from "node:zlib"; +import { collectEntryAssets } from "./performance-entry-assets.mjs"; const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname); const WEB_DIR = path.resolve(SCRIPT_DIR, ".."); const ASSETS_DIR = path.resolve(WEB_DIR, ".vercel/output/static/assets"); +const MANIFEST_PATH = path.resolve(ASSETS_DIR, "../.vite/manifest.json"); const BASELINE_PATH = path.resolve(WEB_DIR, "perf-baseline.json"); const REPORT_DIR = path.resolve(WEB_DIR, "reports/performance"); const CURRENT_REPORT_PATH = path.resolve(REPORT_DIR, "current.json"); @@ -18,8 +20,6 @@ const TRACKED_KEYS = [ "totalJsGzip", ]; -const MAIN_JS_PATTERNS = [/^main-.*\.js$/, /^index-.*\.js$/]; -const MAIN_CSS_PATTERNS = [/^main-.*\.css$/, /^index-.*\.css$/]; const LOCALIZED_CONTENT_JS_PATTERN = /^(?:localized-content-|(?:es|zh-Hant|zh|ja|ko|de|fr|uk)[.-]).*\.js$/; const LAZY_SYNTAX_JS_PATTERN = /^lazy-syntax-(?:language|theme)-.*\.js$/; @@ -54,10 +54,16 @@ async function getFileSize(filePath) { }; } -function findAsset(entries, patterns) { - return [...entries] - .sort((a, b) => a.gzip - b.gzip || a.file.localeCompare(b.file)) - .find((entry) => patterns.some((pattern) => pattern.test(entry.file))); +async function measureEntryAssets(files) { + if (files.length === 0) throw new Error("Required entry assets are missing from client manifest"); + let raw = 0; + let gzip = 0; + for (const file of files) { + const size = await getFileSize(path.resolve(ASSETS_DIR, "..", file)); + raw += size.raw; + gzip += size.gzip; + } + return { file: files.join(", "), raw, gzip }; } function isLocalizedContentAsset(file) { @@ -85,8 +91,10 @@ async function collectMetrics() { cssSizes.push({ file, ...size }); } - const mainJs = findAsset(jsSizes, MAIN_JS_PATTERNS); - const mainCss = findAsset(cssSizes, MAIN_CSS_PATTERNS); + const manifest = JSON.parse(await fs.readFile(MANIFEST_PATH, "utf8")); + const entryAssets = collectEntryAssets(manifest); + const mainJs = await measureEntryAssets(entryAssets.js); + const mainCss = await measureEntryAssets(entryAssets.css); const stackBuilderJs = [...jsSizes] .filter((entry) => /^stack-builder-.*\.js$/.test(entry.file)) .sort((a, b) => b.gzip - a.gzip || a.file.localeCompare(b.file))[0]; @@ -117,6 +125,8 @@ async function collectMetrics() { return { generatedAt: new Date().toISOString(), + entryMeasurementVersion: 2, + entryAssets, assetCount: { js: jsSizes.length, budgetedJs: budgetedJsSizes.length, @@ -205,17 +215,27 @@ async function writeSummary(summaryLines) { await fs.writeFile(SUMMARY_PATH, `${summaryLines.join("\n")}\n`, "utf8"); } -async function updateBaseline(current) { - let budgets = { ...DEFAULT_BUDGETS }; +async function readExistingBaseline() { try { - const existing = JSON.parse(await fs.readFile(BASELINE_PATH, "utf8")); - if (existing?.budgets) budgets = { ...budgets, ...existing.budgets }; - } catch { - // Baseline does not exist yet. + return JSON.parse(await fs.readFile(BASELINE_PATH, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; } +} + +async function updateBaseline(current) { + const existing = await readExistingBaseline(); + if (existing && existing.entryMeasurementVersion !== 2) { + throw new Error( + "Entry measurement changed to the manifest's static dependency graph. Migrate the entry baseline using the pre-change build before updating it; do not reset it to the optimized build.", + ); + } + const budgets = { ...DEFAULT_BUDGETS, ...existing?.budgets }; const baseline = { updatedAt: new Date().toISOString(), + entryMeasurementVersion: 2, metrics: Object.fromEntries(TRACKED_KEYS.map((key) => [key, current.metrics[key]])), budgets, }; @@ -239,6 +259,10 @@ async function checkAgainstBaseline(current) { const baselineRaw = await fs.readFile(BASELINE_PATH, "utf8"); const baseline = JSON.parse(baselineRaw); + if (baseline.entryMeasurementVersion !== 2) { + throw new Error("Entry measurement changed to the manifest's static dependency graph. Migrate the entry baseline using the pre-change build before comparing; do not reset it to the optimized build."); + } + for (const key of TRACKED_KEYS) { if (typeof baseline?.metrics?.[key] !== "number") { throw new Error(`Baseline is missing metric "${key}"`); diff --git a/apps/web/scripts/performance-entry-assets.mjs b/apps/web/scripts/performance-entry-assets.mjs new file mode 100644 index 000000000..e3c7cdfb2 --- /dev/null +++ b/apps/web/scripts/performance-entry-assets.mjs @@ -0,0 +1,22 @@ +export function collectEntryAssets(manifest) { + const entryKeys = Object.keys(manifest).filter( + (key) => manifest[key].isEntry && manifest[key].file.endsWith(".js"), + ); + if (entryKeys.length === 0) throw new Error("Client manifest has no JavaScript entry"); + + const visited = new Set(); + const js = new Set(); + const css = new Set(); + const visit = (key) => { + if (visited.has(key)) return; + const chunk = manifest[key]; + if (!chunk) throw new Error(`Missing static dependency in client manifest: ${key}`); + visited.add(key); + js.add(chunk.file); + for (const file of chunk.css ?? []) css.add(file); + for (const dependency of chunk.imports ?? []) visit(dependency); + }; + for (const key of entryKeys) visit(key); + + return { js: [...js].sort(), css: [...css].sort() }; +} diff --git a/apps/web/src/assets/fonts/Geist-Variable.woff2 b/apps/web/src/assets/fonts/Geist-Variable.woff2 new file mode 100644 index 000000000..b2f012106 Binary files /dev/null and b/apps/web/src/assets/fonts/Geist-Variable.woff2 differ diff --git a/apps/web/src/assets/fonts/GeistMono-Variable.woff2 b/apps/web/src/assets/fonts/GeistMono-Variable.woff2 new file mode 100644 index 000000000..dbdb8c2df Binary files /dev/null and b/apps/web/src/assets/fonts/GeistMono-Variable.woff2 differ diff --git a/apps/web/src/components/campaign/run-before-clone-page.tsx b/apps/web/src/components/campaign/run-before-clone-page.tsx index 4a60d9042..0ad9df420 100644 --- a/apps/web/src/components/campaign/run-before-clone-page.tsx +++ b/apps/web/src/components/campaign/run-before-clone-page.tsx @@ -13,7 +13,7 @@ import { } from "react-icons/tb"; import { TechIcon } from "@/components/ui/tech-icon"; -import { trackCampaignEvent } from "@/lib/analytics/campaign-analytics"; +import { trackCampaignEvent } from "@/lib/analytics/campaign-events"; import { CAMPAIGN_BUILDER_SEARCH, CAMPAIGN_PRESETS, diff --git a/apps/web/src/components/changelog-widget.tsx b/apps/web/src/components/changelog-widget.tsx index 80793db89..37ab4bf98 100644 --- a/apps/web/src/components/changelog-widget.tsx +++ b/apps/web/src/components/changelog-widget.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; -import { ChangelogModal } from "@/components/changelog-modal"; import { registerVisit } from "@/lib/analytics/visitor"; import { latestChangelogRelease } from "@/lib/content/changelog"; import { @@ -13,6 +12,11 @@ import { getLocaleDateTag } from "@/lib/i18n/locales"; import { m } from "@/paraglide/messages.js"; import { getLocale } from "@/paraglide/runtime.js"; +const ChangelogModal = lazy(async () => { + const { ChangelogModal } = await import("@/components/changelog-modal"); + return { default: ChangelogModal }; +}); + function formatReleaseDate(publishedAt: string, fallback: string): string { const parsed = new Date(publishedAt); if (Number.isNaN(parsed.getTime())) return fallback; @@ -27,6 +31,7 @@ function formatReleaseDate(publishedAt: string, fallback: string): string { export function ChangelogWidget() { const [isVisible, setIsVisible] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const [hasOpenedModal, setHasOpenedModal] = useState(false); useEffect(() => { if (!latestChangelogRelease) return; @@ -65,6 +70,7 @@ export function ChangelogWidget() { markInteracted("opened"); setIsVisible(false); + setHasOpenedModal(true); setIsModalOpen(true); }, [markInteracted]); @@ -148,7 +154,11 @@ export function ChangelogWidget() { ) : null} - + {hasOpenedModal && ( + + + + )} ); } diff --git a/apps/web/src/components/effects/shader-canvas.tsx b/apps/web/src/components/effects/shader-canvas.tsx index 8b48f7c13..db67ec185 100644 --- a/apps/web/src/components/effects/shader-canvas.tsx +++ b/apps/web/src/components/effects/shader-canvas.tsx @@ -39,6 +39,11 @@ export function ShaderCanvas({ fragmentShader, className, uniforms, paused }: Sh uniformsRef.current = uniforms; const pausedRef = useRef(paused); pausedRef.current = paused; + const updatePlaybackRef = useRef<(() => void) | null>(null); + + useEffect(() => { + updatePlaybackRef.current?.(); + }, [paused]); useEffect(() => { const canvas = canvasRef.current; @@ -73,6 +78,8 @@ export function ShaderCanvas({ fragmentShader, className, uniforms, paused }: Sh const uResolution = gl.getUniformLocation(program, "u_resolution"); let raf = 0; + let inView = false; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const start = performance.now(); let lastT = 0; @@ -91,6 +98,8 @@ export function ShaderCanvas({ fragmentShader, className, uniforms, paused }: Sh gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); const render = () => { + raf = 0; + if (!inView || document.hidden) return; if (!pausedRef.current) { resize(); const t = (performance.now() - start) / 1000; @@ -122,22 +131,30 @@ export function ShaderCanvas({ fragmentShader, className, uniforms, paused }: Sh } else { gl.uniform1f(uTime, lastT); } - raf = requestAnimationFrame(render); - }; - render(); - - const onVisibility = () => { - if (document.hidden) { - cancelAnimationFrame(raf); - } else { + if (!pausedRef.current && !reducedMotion.matches) { raf = requestAnimationFrame(render); } }; - document.addEventListener("visibilitychange", onVisibility); + + const updatePlayback = () => { + cancelAnimationFrame(raf); + render(); + }; + updatePlaybackRef.current = updatePlayback; + const observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + updatePlayback(); + }); + observer.observe(canvas); + document.addEventListener("visibilitychange", updatePlayback); + reducedMotion.addEventListener("change", updatePlayback); return () => { + updatePlaybackRef.current = null; cancelAnimationFrame(raf); - document.removeEventListener("visibilitychange", onVisibility); + observer.disconnect(); + document.removeEventListener("visibilitychange", updatePlayback); + reducedMotion.removeEventListener("change", updatePlayback); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); diff --git a/apps/web/src/components/home/combinations-section.tsx b/apps/web/src/components/home/combinations-section.tsx index adc12e047..e7dc76831 100644 --- a/apps/web/src/components/home/combinations-section.tsx +++ b/apps/web/src/components/home/combinations-section.tsx @@ -1,14 +1,17 @@ -import { motion } from "motion/react"; -import { useEffect, useMemo, useState } from "react"; +import { motion, useInView, useReducedMotion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import { combinationsMetrics } from "@/lib/builder/combinations-count"; +import { HOME_COMBINATIONS_METRICS } from "@/lib/project/home-display-data"; import { PROJECT_ECOSYSTEM_COPY } from "@/lib/project/project-stats"; import { m } from "@/paraglide/messages.js"; const { totalScientific, yearsAtOneMillisecondScientific, universeLifetimesScientific } = - combinationsMetrics; + HOME_COMBINATIONS_METRICS; export default function CombinationsSection() { + const sectionRef = useRef(null); + const inView = useInView(sectionRef); + const reducedMotion = useReducedMotion(); const funFacts = useMemo( () => [ m.homeFactUniverseLifetimes({ @@ -16,8 +19,8 @@ export default function CombinationsSection() { exponent: universeLifetimesScientific.exponent, }), m.homeFactSand({ - mantissa: combinationsMetrics.universeSandRatioScientific.mantissa, - exponent: combinationsMetrics.universeSandRatioScientific.exponent, + mantissa: HOME_COMBINATIONS_METRICS.universeSandRatioScientific.mantissa, + exponent: HOME_COMBINATIONS_METRICS.universeSandRatioScientific.exponent, }), m.homeFactEcosystems(PROJECT_ECOSYSTEM_COPY), m.homeFactUnique(), @@ -28,14 +31,26 @@ export default function CombinationsSection() { const [factIndex, setFactIndex] = useState(0); useEffect(() => { - const id = window.setInterval(() => { - setFactIndex((i) => (i + 1) % funFacts.length); - }, 4000); - return () => window.clearInterval(id); - }, [funFacts.length]); + if (!inView || reducedMotion) return; + let interval: number | undefined; + const update = () => { + window.clearInterval(interval); + if (!document.hidden) { + interval = window.setInterval(() => { + setFactIndex((i) => (i + 1) % funFacts.length); + }, 4000); + } + }; + update(); + document.addEventListener("visibilitychange", update); + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", update); + }; + }, [funFacts.length, inView, reducedMotion]); return ( -
+
diff --git a/apps/web/src/components/home/features-section.tsx b/apps/web/src/components/home/features-section.tsx index 749efe567..f5c20883e 100644 --- a/apps/web/src/components/home/features-section.tsx +++ b/apps/web/src/components/home/features-section.tsx @@ -1,15 +1,13 @@ import NumberFlow from "@number-flow/react"; import { Link } from "@tanstack/react-router"; import { motion, useInView } from "motion/react"; -import { lazy, Suspense, useMemo, useRef } from "react"; +import { lazy, Suspense, useRef } from "react"; import { TbArrowRight as ArrowRight } from "react-icons/tb"; -import type { TechCategory } from "@/lib/stack/types"; - import { ContainerScroll } from "@/components/effects/container-scroll"; import { TechIcon } from "@/components/ui/tech-icon"; +import { HOME_FEATURE_OPTIONS } from "@/lib/project/home-display-data"; import { OPTION_ENTRY_COUNT, PROJECT_ECOSYSTEM_COPY } from "@/lib/project/project-stats"; -import { ECOSYSTEMS, TECH_OPTIONS } from "@/lib/stack/constant"; import { m } from "@/paraglide/messages.js"; const WebGLShader = lazy(async () => { @@ -17,76 +15,32 @@ const WebGLShader = lazy(async () => { return { default: m.WebGLShader }; }); -type Layer = - | { type: "ecosystems"; key: string; word: () => string } - | { type: "categories"; categories: TechCategory[]; key: string; word: () => string }; +type Layer = { key: keyof typeof HOME_FEATURE_OPTIONS; word: () => string }; const LAYERS: ReadonlyArray = [ - { type: "ecosystems", key: "ecosystems", word: m.homeLayerLanguageEcosystems }, + { key: "ecosystems", word: m.homeLayerLanguageEcosystems }, { - type: "categories", - categories: ["webFrontend", "rustFrontend"], key: "frontend", word: m.homeLayerFrontendFrameworks, }, { - type: "categories", - categories: [ - "backend", - "rustWebFramework", - "pythonWebFramework", - "goWebFramework", - "javaWebFramework", - "elixirWebFramework", - "dotnetWebFramework", - ], key: "backend", word: m.homeLayerBackendFrameworks, }, { - type: "categories", - categories: ["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm", "elixirOrm", "dotnetOrm"], key: "orm", word: m.homeLayerDatabaseOrms, }, { - type: "categories", - categories: [ - "auth", - "rustAuth", - "pythonAuth", - "goAuth", - "javaAuth", - "elixirAuth", - "dotnetAuth", - ], key: "auth", word: m.homeLayerAuthProviders, }, { - type: "categories", - categories: ["ai", "pythonAi"], key: "ai", word: m.homeLayerAiIntegrations, }, ]; -function getOptions(categories: TechCategory[]) { - const seen = new Set(); - const results: { id: string; name: string }[] = []; - - for (const cat of categories) { - for (const opt of TECH_OPTIONS[cat] ?? []) { - if (!opt.legacy && opt.id !== "none" && !seen.has(opt.id)) { - seen.add(opt.id); - results.push({ id: opt.id, name: opt.name }); - } - } - } - - return results; -} - export default function FeaturesSection() { return (
@@ -172,12 +126,7 @@ function LayerRow({ layer, index }: { layer: Layer; index: number }) { const inView = useInView(ref, { once: true, margin: "-20%" }); const flip = index % 2 === 1; - const options = useMemo(() => { - if (layer.type === "ecosystems") { - return ECOSYSTEMS.map((e) => ({ id: e.id, name: e.name })); - } - return getOptions(layer.categories); - }, [layer]); + const options = HOME_FEATURE_OPTIONS[layer.key]; return (
  • {options.map((opt, j) => - layer.type === "ecosystems" ? ( + layer.key === "ecosystems" ? (
    - {m.homeStarterTitleA()}{" "} {m.homeStarterTitleB()} - + - +

    {m .homeStarterSubtitle() .split("→") @@ -101,14 +92,9 @@ export default function HeroSection() { )} ))} - +

    - +
    {SHAPES.map((entry) => (
    - +
    - +
      {LIKED_BY.map((person) => (
    -
    +
  • ); diff --git a/apps/web/src/components/stack-builder/capability-evidence-badge.tsx b/apps/web/src/components/stack-builder/capability-evidence-badge.tsx index 24c782da3..8867ac7b4 100644 --- a/apps/web/src/components/stack-builder/capability-evidence-badge.tsx +++ b/apps/web/src/components/stack-builder/capability-evidence-badge.tsx @@ -4,7 +4,7 @@ import { type OptionCategory, type OptionCategoryEcosystem, } from "@better-fullstack/types"; -import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, memo, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; import type { PublicCapabilityEvidenceReport } from "@/lib/docs/release-verification"; @@ -15,6 +15,13 @@ const BASELINE_INVENTORY = getCapabilityInventory(); const CapabilityEvidenceContext = createContext(BASELINE_INVENTORY); +const CapabilityEvidenceLookupContext = createContext( + new Map(BASELINE_INVENTORY.map((record) => [evidenceKey(record), record])), +); + +function evidenceKey(record: { ecosystem: string; category: string; optionId: string }) { + return `${record.ecosystem}:${record.category}:${record.optionId}`; +} export function useCapabilityEvidenceInventory() { return useContext(CapabilityEvidenceContext); @@ -23,6 +30,10 @@ export function useCapabilityEvidenceInventory() { export function CapabilityEvidenceProvider({ children }: { children: ReactNode }) { const [inventory, setInventory] = useState(BASELINE_INVENTORY); + const lookup = useMemo( + () => new Map(inventory.map((record) => [evidenceKey(record), record])), + [inventory], + ); useEffect(() => { const controller = new AbortController(); @@ -44,7 +55,9 @@ export function CapabilityEvidenceProvider({ children }: { children: ReactNode } return ( - {children} + + {children} + ); } @@ -54,17 +67,8 @@ function useCapabilityEvidence( category: OptionCategory, optionId: string, ): CapabilityInventoryRecord | undefined { - const inventory = useCapabilityEvidenceInventory(); - return useMemo( - () => - inventory.find( - (record) => - record.ecosystem === ecosystem && - record.category === category && - record.optionId === optionId, - ), - [category, ecosystem, inventory, optionId], - ); + const lookup = useContext(CapabilityEvidenceLookupContext); + return lookup.get(evidenceKey({ ecosystem, category, optionId })); } const EVIDENCE_LABELS = { @@ -73,7 +77,7 @@ const EVIDENCE_LABELS = { "runtime-verified": "Runtime verified", } as const; -export function CapabilityEvidenceBadge({ +export const CapabilityEvidenceBadge = memo(function CapabilityEvidenceBadge({ ecosystem, category, optionId, @@ -119,4 +123,4 @@ export function CapabilityEvidenceBadge({ ); -} +}); diff --git a/apps/web/src/components/stack-builder/secondary-panels.ts b/apps/web/src/components/stack-builder/secondary-panels.ts new file mode 100644 index 000000000..935c1f0be --- /dev/null +++ b/apps/web/src/components/stack-builder/secondary-panels.ts @@ -0,0 +1,5 @@ +// Keep secondary views out of startup while sharing their dialog and translation code. +export { BuilderShareModal } from "@/components/stack-builder/builder-share-modal"; +export { ExistingProjectImportDialog } from "@/components/stack-builder/existing-project-import-dialog"; +export { PresetsPanel } from "@/components/stack-builder/presets-panel"; +export { SavedStacksPanel } from "@/components/stack-builder/saved-stacks-panel"; diff --git a/apps/web/src/components/stack-builder/stack-builder-page.tsx b/apps/web/src/components/stack-builder/stack-builder-page.tsx index 9f46d19c1..ec8dd5f7a 100644 --- a/apps/web/src/components/stack-builder/stack-builder-page.tsx +++ b/apps/web/src/components/stack-builder/stack-builder-page.tsx @@ -1,25 +1,11 @@ -import { Suspense, lazy } from "react"; - import type { StackState } from "@/lib/stack/stack-defaults"; -import { m } from "@/paraglide/messages.js"; - -const StackBuilder = lazy(() => import("@/components/stack-builder/stack-builder")); - -function BuilderFallback() { - return ( -
    - {m.builderLoading()} -
    - ); -} +import StackBuilder from "@/components/stack-builder/stack-builder"; export function StackBuilderPage({ initialStack }: { initialStack?: StackState }) { return ( - }> -
    - -
    -
    +
    + +
    ); } diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index bd694a2ce..70832c069 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -19,10 +19,12 @@ import { Fragment, Suspense, lazy, + memo, startTransition, type ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -62,15 +64,11 @@ import { toast } from "sonner"; import type { ShareMoment } from "@/lib/campaign/campaign-share"; import type { Ecosystem } from "@/lib/stack/types"; -import { BuilderShareModal } from "@/components/stack-builder/builder-share-modal"; import { CapabilityEvidenceBadge, CapabilityEvidenceProvider, useCapabilityEvidenceInventory, } from "@/components/stack-builder/capability-evidence-badge"; -import { ExistingProjectImportDialog } from "@/components/stack-builder/existing-project-import-dialog"; -import { PresetsPanel } from "@/components/stack-builder/presets-panel"; -import { SavedStacksPanel } from "@/components/stack-builder/saved-stacks-panel"; import { type BuilderSectionDef, getBuilderSections, @@ -1252,7 +1250,13 @@ function getCategoryRenderGroups( })); } -function TechResourceButtons({ category, techId }: { category: string; techId: string }) { +const TechResourceButtons = memo(function TechResourceButtons({ + category, + techId, +}: { + category: string; + techId: string; +}) { const { docsUrl, githubUrl } = getTechResourceLinks(category, techId); if (!docsUrl && !githubUrl) return null; @@ -1302,7 +1306,84 @@ function TechResourceButtons({ category, techId }: { category: string; techId: s )} ); -} +}); + +const TechOptionCard = memo(function TechOptionCard({ + tech, + category, + ecosystem, + isSelected, + isDisabled, + disabledReason, + description, + onSelect, +}: { + tech: TechOption; + category: keyof typeof TECH_OPTIONS; + ecosystem: OptionCategoryEcosystem; + isSelected: boolean; + isDisabled: boolean; + disabledReason: string | null; + description: string; + onSelect: (category: keyof typeof TECH_OPTIONS, techId: string) => void; +}) { + return ( + { + e.stopPropagation(); + onSelect(category, tech.id); + }} + title={disabledReason || undefined} + > +
    + + {tech.default && !isSelected && ( + + {m.builderDefault()} + + )} +
    +
    + {(tech.icon !== "" || ICON_REGISTRY[tech.id]) && ( +
    +
    + +
    +
    + )} +
    + + {tech.name} + +

    + {description} +

    + + {isDisabled && disabledReason && } +
    +
    +
    + ); +}); function DisabledReasonInline({ reason, compact = false }: { reason: string; compact?: boolean }) { return ( @@ -2557,6 +2638,26 @@ function CreationModeComposer({ ); } +const BuilderShareModal = lazy(async () => { + const module = await import("@/components/stack-builder/secondary-panels"); + return { default: module.BuilderShareModal }; +}); + +const ExistingProjectImportDialog = lazy(async () => { + const module = await import("@/components/stack-builder/secondary-panels"); + return { default: module.ExistingProjectImportDialog }; +}); + +const PresetsPanel = lazy(async () => { + const module = await import("@/components/stack-builder/secondary-panels"); + return { default: module.PresetsPanel }; +}); + +const SavedStacksPanel = lazy(async () => { + const module = await import("@/components/stack-builder/secondary-panels"); + return { default: module.SavedStacksPanel }; +}); + // ─── Main Component ────────────────────────────────────────────────────────── const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { @@ -2573,7 +2674,6 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { ] = useStackState(initialStack); const evidenceInventory = useCapabilityEvidenceInventory(); - const [command, setCommand] = useState(""); const [copied, setCopied] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -2584,6 +2684,8 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { const [isDownloadingProject, setIsDownloadingProject] = useState(false); const [sharePromptOpen, setSharePromptOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); + const [hasOpenedImport, setHasOpenedImport] = useState(false); + const [hasOpenedSharePrompt, setHasOpenedSharePrompt] = useState(false); const [sharePromptMoment, setSharePromptMoment] = useState("run"); const [pendingUpdateEntryId, setPendingUpdateEntryId] = useState(null); const [multiActiveStep, setMultiActiveStep] = useState("frontend"); @@ -2611,7 +2713,7 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }; - const compatibilityAnalysis = analyzeStackCompatibility(stack); + const compatibilityAnalysis = useMemo(() => analyzeStackCompatibility(stack), [stack]); const adjustedStack = useMemo(() => { if (!compatibilityAnalysis.adjustedStack) return null; return { ...stack, ...compatibilityAnalysis.adjustedStack }; @@ -2658,6 +2760,7 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { const stackToShare = adjustedStack || stack; setSharePromptMoment(moment); + setHasOpenedSharePrompt(true); setSharePromptOpen(true); trackCampaignEvent( "builder_share_prompted", @@ -2868,12 +2971,11 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { } }, [adjustedStack, campaign, compatibilityAnalysis.changes, evidenceInventory, setStack]); - useEffect(() => { + const command = useMemo(() => { const stackToUse = adjustedStack || stack; const projectName = stackToUse.projectName || "my-app"; const formattedProjectName = formatProjectName(projectName); - const cmd = generateStackCommand({ ...stackToUse, projectName: formattedProjectName }); - setCommand(cmd); + return generateStackCommand({ ...stackToUse, projectName: formattedProjectName }); }, [stack, adjustedStack]); useEffect(() => { @@ -2917,12 +3019,9 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { } }, [runSupported, setViewMode, viewMode]); - // Warm the run-panel chunk so the first switch to the Run tab mounts it - // synchronously - a suspended mount would delay the copy button's - // shared-layout landing target past the command bar's exit. - useEffect(() => { + const warmRunPanel = () => { if (runSupported) void import("@/components/stack-builder/run-panel"); - }, [runSupported]); + }; const handleRunStarted = useCallback( (rerun: boolean) => { @@ -3032,6 +3131,15 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { }); }; + // Cards keep a stable handler while selection checks use the latest committed stack. + const handleTechSelectRef = useRef(handleTechSelect); + useLayoutEffect(() => { + handleTechSelectRef.current = handleTechSelect; + }); + const selectTech = useCallback((category: keyof typeof TECH_OPTIONS, techId: string) => { + handleTechSelectRef.current(category, techId); + }, []); + const handleMultiActiveStepChange = (stepId: MultiStackStepId) => { setMultiActiveStep(stepId); }; @@ -3352,19 +3460,27 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => { - - + {hasOpenedSharePrompt && ( + + + + )} + {hasOpenedImport && ( + + + + )}
    {/* Single scroller: header + toolbar + content scroll together (header is not pinned) */}
    {
    @@ -4471,29 +4529,41 @@ const StackBuilderInner = ({ initialStack }: { initialStack?: StackState }) => {
    ) : viewMode === "presets" ? (
    - { - applyPreset(presetId); - setViewMode("command"); - }} - starterTrackFilters={starterTrackFilters} - onStarterTrackFiltersChange={updateStarterTrackFilters} - /> + {m.builderLoading()}
    + } + > + { + applyPreset(presetId); + setViewMode("command"); + }} + starterTrackFilters={starterTrackFilters} + onStarterTrackFiltersChange={updateStarterTrackFilters} + /> + ) : (
    - + {m.builderLoading()}
    + } + > + + )} diff --git a/apps/web/src/components/ui/hand-drawn-new-callout.tsx b/apps/web/src/components/ui/hand-drawn-new-callout.tsx index 1c55e3e57..e0e0245d1 100644 --- a/apps/web/src/components/ui/hand-drawn-new-callout.tsx +++ b/apps/web/src/components/ui/hand-drawn-new-callout.tsx @@ -162,6 +162,11 @@ export function HandDrawnNewCallout({ className }: HandDrawnNewCalloutProps) { return (
    + ; +export { + trackCampaignEvent, + sanitizeCampaignProperties, + type CampaignEvent, +} from "@/lib/analytics/campaign-events"; const BACKEND_KEY_BY_ECOSYSTEM = { typescript: "backend", @@ -68,39 +43,6 @@ function soloBackend(stack: StackState) { return typeof value === "string" ? value : "none"; } -export function trackCampaignEvent(event: CampaignEvent, properties?: CampaignProperties) { - if (!isBrowserTelemetryEnabled()) return; - const safeProperties = sanitizeCampaignProperties(properties); - track(event, safeProperties); - const status = event.endsWith("_failed") - ? "failed" - : event.endsWith("_abandoned") - ? "cancelled" - : event.endsWith("_started") || event.endsWith("_viewed") || event.endsWith("_opened") - ? "started" - : "succeeded"; - const productProperties = { ...safeProperties }; - if (status === "failed") { - productProperties.failure_stage = productProperties.stage; - productProperties.failure_reason = productProperties.reason; - delete productProperties.stage; - delete productProperties.reason; - } - trackProductEvent(event.replaceAll("_", "-"), status, productProperties); -} - -export function sanitizeCampaignProperties( - properties: CampaignProperties = {}, -): CampaignProperties { - const safe = sanitizeProductProperties(properties); - if (safe.campaign !== undefined) { - const campaign = normalizeCampaignSlug(safe.campaign); - if (campaign) safe.campaign = campaign; - else delete safe.campaign; - } - return safe; -} - export function stackAnalyticsProperties( stack: StackState, extra?: CampaignProperties, diff --git a/apps/web/src/lib/analytics/campaign-events.ts b/apps/web/src/lib/analytics/campaign-events.ts new file mode 100644 index 000000000..80a0d4df1 --- /dev/null +++ b/apps/web/src/lib/analytics/campaign-events.ts @@ -0,0 +1,64 @@ +import { track } from "@vercel/analytics"; + +import { + isBrowserTelemetryEnabled, + sanitizeProductProperties, + trackProductEvent, +} from "@/lib/analytics/product-analytics"; +import { normalizeCampaignSlug } from "@/lib/campaign/campaign"; + +export type CampaignEvent = + | "campaign_viewed" + | "campaign_preset_opened" + | "builder_viewed" + | "builder_view_changed" + | "builder_command_copied" + | "builder_run_started" + | "builder_run_ready" + | "builder_run_failed" + | "builder_run_stopped" + | "builder_file_edited" + | "builder_zip_started" + | "builder_zip_downloaded" + | "builder_zip_failed" + | "builder_share_prompted" + | "builder_stack_shared" + | "builder_github_clicked" + | "builder_starter_track_applied" + | "builder_incompatibility_recovered" + | "builder_plan_abandoned"; + +export type CampaignProperties = Record; + +export function trackCampaignEvent(event: CampaignEvent, properties?: CampaignProperties) { + if (!isBrowserTelemetryEnabled()) return; + const safeProperties = sanitizeCampaignProperties(properties); + track(event, safeProperties); + const status = event.endsWith("_failed") + ? "failed" + : event.endsWith("_abandoned") + ? "cancelled" + : event.endsWith("_started") || event.endsWith("_viewed") || event.endsWith("_opened") + ? "started" + : "succeeded"; + const productProperties = { ...safeProperties }; + if (status === "failed") { + productProperties.failure_stage = productProperties.stage; + productProperties.failure_reason = productProperties.reason; + delete productProperties.stage; + delete productProperties.reason; + } + trackProductEvent(event.replaceAll("_", "-"), status, productProperties); +} + +export function sanitizeCampaignProperties( + properties: CampaignProperties = {}, +): CampaignProperties { + const safe = sanitizeProductProperties(properties); + if (safe.campaign !== undefined) { + const campaign = normalizeCampaignSlug(safe.campaign); + if (campaign) safe.campaign = campaign; + else delete safe.campaign; + } + return safe; +} diff --git a/apps/web/src/lib/docs/frontmatter.ts b/apps/web/src/lib/docs/frontmatter.ts new file mode 100644 index 000000000..b4c95edef --- /dev/null +++ b/apps/web/src/lib/docs/frontmatter.ts @@ -0,0 +1,17 @@ +import type { DocPage, DocFrontmatter } from "@/lib/docs/source"; + +import { toSupportedLocale } from "@/lib/i18n/locales"; +import { getLocale } from "@/paraglide/runtime.js"; + +export function getLocalizedDocFrontmatter( + page: Pick, + locale = toSupportedLocale(getLocale()) ?? "en", +): DocFrontmatter { + if (locale === "en" || page.frontmatter.translationStatus === "pending") { + return page.frontmatter; + } + return { + ...page.frontmatter, + ...page.localizedFrontmatter?.[locale], + }; +} diff --git a/apps/web/src/lib/docs/source.ts b/apps/web/src/lib/docs/source.ts index 0d29b59a5..fc70fcd60 100644 --- a/apps/web/src/lib/docs/source.ts +++ b/apps/web/src/lib/docs/source.ts @@ -4,6 +4,7 @@ import { localizedDocsMdxLoaders, localizedDocsRawMdxLoaders } from "virtual:loc import type { TocEntry } from "@/lib/docs/remark-extract-toc"; import { createSuspenseCache } from "@/lib/content/mdx-suspense-cache"; +import { getLocalizedDocFrontmatter } from "@/lib/docs/frontmatter"; import { docsMdxLoaders as mdxLoaders, docsRawMdxLoaders as rawMdxLoaders, @@ -16,6 +17,8 @@ import { } from "@/lib/i18n/locales"; import { getLocale } from "@/paraglide/runtime.js"; +export { getLocalizedDocFrontmatter } from "@/lib/docs/frontmatter"; + export type DocFrontmatter = { title?: string; description?: string; @@ -214,19 +217,6 @@ function localizedFolderName(name: string, locale = currentContentLocale()): str return DOC_FOLDER_TITLE_TRANSLATIONS[name]?.[locale]?.title ?? name; } -export function getLocalizedDocFrontmatter( - page: Pick, - locale = currentContentLocale(), -): DocFrontmatter { - if (locale === "en" || page.frontmatter.translationStatus === "pending") { - return page.frontmatter; - } - return { - ...page.frontmatter, - ...page.localizedFrontmatter?.[locale], - }; -} - export function localizeDocPage(page: DocPage): DocPage { return { ...page, diff --git a/apps/web/src/lib/project/home-display-data.ts b/apps/web/src/lib/project/home-display-data.ts new file mode 100644 index 000000000..3b68c739e --- /dev/null +++ b/apps/web/src/lib/project/home-display-data.ts @@ -0,0 +1,54 @@ +import { combinationsMetrics } from "#web/lib/builder/combinations-count"; +import { ECOSYSTEMS, TECH_OPTIONS } from "#web/lib/stack/constant"; + +import type { TechCategory } from "@/lib/stack/types"; + +export { PACKAGE_MANAGER_COMMANDS } from "@better-fullstack/types"; + +export const HOME_COMBINATIONS_METRICS = { + totalScientific: combinationsMetrics.totalScientific, + yearsAtOneMillisecondScientific: combinationsMetrics.yearsAtOneMillisecondScientific, + universeLifetimesScientific: combinationsMetrics.universeLifetimesScientific, + universeSandRatioScientific: combinationsMetrics.universeSandRatioScientific, +}; + +function getOptions(categories: TechCategory[]) { + const seen = new Set(); + const results: { id: string; name: string }[] = []; + + for (const cat of categories) { + for (const opt of TECH_OPTIONS[cat] ?? []) { + if (!opt.legacy && opt.id !== "none" && !seen.has(opt.id)) { + seen.add(opt.id); + results.push({ id: opt.id, name: opt.name }); + } + } + } + + return results; +} + +export const HOME_FEATURE_OPTIONS = { + ecosystems: ECOSYSTEMS.map(({ id, name }) => ({ id, name })), + frontend: getOptions(["webFrontend", "rustFrontend"]), + backend: getOptions([ + "backend", + "rustWebFramework", + "pythonWebFramework", + "goWebFramework", + "javaWebFramework", + "elixirWebFramework", + "dotnetWebFramework", + ]), + orm: getOptions(["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm", "elixirOrm", "dotnetOrm"]), + auth: getOptions([ + "auth", + "rustAuth", + "pythonAuth", + "goAuth", + "javaAuth", + "elixirAuth", + "dotnetAuth", + ]), + ai: getOptions(["ai", "pythonAi"]), +}; diff --git a/apps/web/src/lib/stack/constant.ts b/apps/web/src/lib/stack/constant.ts index 42066ff7f..d5f6d48b4 100644 --- a/apps/web/src/lib/stack/constant.ts +++ b/apps/web/src/lib/stack/constant.ts @@ -8,7 +8,7 @@ import { import type { Ecosystem, TechCategory } from "@/lib/stack/types"; -import { DEFAULT_STACK, isStackDefault, type StackState } from "@/lib/stack/stack-defaults"; +import { DEFAULT_STACK, isStackDefault, type StackState } from "#web/lib/stack/stack-defaults"; const AUTH_TECH_OPTIONS = getCapabilityDefinitions("auth").map((cap) => ({ id: cap.id, diff --git a/apps/web/src/routes/$stackShare.tsx b/apps/web/src/routes/$stackShare.tsx index 4d6c6fb9b..52abe15d8 100644 --- a/apps/web/src/routes/$stackShare.tsx +++ b/apps/web/src/routes/$stackShare.tsx @@ -2,7 +2,6 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { StackBuilderPage } from "@/components/stack-builder/stack-builder-page"; import { buildPageHead, getEcosystemOgImage, SITE_NAME } from "@/lib/seo/seo"; -import { parseStackShareSlug } from "@/lib/stack/stack-share-paths"; import { getCanonicalStackSharePath, normalizeStackShareSlug } from "@/lib/stack/stack-share-slugs"; const STACK_SHARE_LABELS = { @@ -18,7 +17,8 @@ const STACK_SHARE_LABELS = { } as const; export const Route = createFileRoute("/$stackShare")({ - loader: ({ params }) => { + loader: async ({ params }) => { + const { parseStackShareSlug } = await import("@/lib/stack/stack-share-paths"); const stack = parseStackShareSlug(params.stackShare); if (!stack) throw notFound(); return { stack }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 39734d1fc..4ea6b6b26 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,8 +1,10 @@ import { Outlet, HeadContent, Scripts, createRootRoute, Link } from "@tanstack/react-router"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/react"; -import { lazy, Suspense, type ReactNode, useSyncExternalStore } from "react"; +import { lazy, Suspense, type ReactNode, useEffect, useSyncExternalStore } from "react"; +import geistSansUrl from "@/assets/fonts/Geist-Variable.woff2"; +import geistMonoUrl from "@/assets/fonts/GeistMono-Variable.woff2"; import { Navbar } from "@/components/navbar"; import Providers from "@/components/providers"; import { @@ -187,26 +189,18 @@ export const Route = createRootRoute({ { rel: "manifest", href: "/favicon/site.webmanifest" }, { rel: "preload", - href: "/fonts/Geist-Variable.woff2", + href: geistSansUrl, as: "font", type: "font/woff2", crossOrigin: "anonymous", }, { rel: "preload", - href: "/fonts/GeistMono-Variable.woff2", + href: geistMonoUrl, as: "font", type: "font/woff2", crossOrigin: "anonymous", }, - // Caveat is loaded as a head link (not a CSS @import) so it doesn't - // block the main stylesheet from applying. - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Figtree:wght@600;700&display=swap", - }, ], }; }, @@ -214,6 +208,12 @@ export const Route = createRootRoute({ }); function RootComponent() { + // Browser tests wait for this before clicking; server HTML now includes + // interactive content whose handlers attach only after hydration. + useEffect(() => { + document.documentElement.dataset.hydrated = "true"; + }, []); + return ( diff --git a/apps/web/src/routes/blog/$.tsx b/apps/web/src/routes/blog/$.tsx index a0043ca12..60b96b94d 100644 --- a/apps/web/src/routes/blog/$.tsx +++ b/apps/web/src/routes/blog/$.tsx @@ -7,6 +7,7 @@ import { localizeBlogFrontmatter, localizeBlogPost } from "@/lib/i18n/content-co import { m } from "@/paraglide/messages.js"; export const Route = createFileRoute("/blog/$")({ + codeSplitGroupings: [["loader"], ["component"]], loader: ({ params }) => { const slug = (params._splat ?? "").split("/").filter(Boolean); const post = getBlogPost(slug); diff --git a/apps/web/src/routes/docs/$.tsx b/apps/web/src/routes/docs/$.tsx index 77577df63..703bfacf0 100644 --- a/apps/web/src/routes/docs/$.tsx +++ b/apps/web/src/routes/docs/$.tsx @@ -1,13 +1,9 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { DocsPageContent } from "@/components/docs/docs-page"; +import { getLocalizedDocFrontmatter } from "@/lib/docs/frontmatter"; import { docsPageHead } from "@/lib/docs/seo"; -import { - getLocalizedDocFrontmatter, - getNeighbors, - getPage, - preloadDocPageContent, -} from "@/lib/docs/source"; +import { getNeighbors, getPage, preloadDocPageContent } from "@/lib/docs/source"; /** * Catch-all for nested docs paths (`/docs/cli/create`, `/docs/ecosystems/multi-ecosystem`, @@ -16,6 +12,7 @@ import { * `[[...slug]]`). Both routes render the same component below. */ export const Route = createFileRoute("/docs/$")({ + codeSplitGroupings: [["loader"], ["component"]], loader: ({ params }) => { const splat = params._splat ?? ""; const slug = splat.split("/").filter(Boolean); diff --git a/apps/web/src/routes/docs/index.tsx b/apps/web/src/routes/docs/index.tsx index 8d7934155..165399372 100644 --- a/apps/web/src/routes/docs/index.tsx +++ b/apps/web/src/routes/docs/index.tsx @@ -1,13 +1,9 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { DocsPageContent } from "@/components/docs/docs-page"; +import { getLocalizedDocFrontmatter } from "@/lib/docs/frontmatter"; import { docsPageHead } from "@/lib/docs/seo"; -import { - getLocalizedDocFrontmatter, - getNeighbors, - getPage, - preloadDocPageContent, -} from "@/lib/docs/source"; +import { getNeighbors, getPage, preloadDocPageContent } from "@/lib/docs/source"; /** * Exact match for `/docs` - renders the docs index page (`content/docs/index.mdx`). @@ -17,6 +13,7 @@ import { * delegate to `` so the rendered chrome is identical. */ export const Route = createFileRoute("/docs/")({ + codeSplitGroupings: [["loader"], ["component"]], loader: () => { const page = getPage([]); if (!page) throw notFound(); diff --git a/apps/web/src/routes/guides/$.tsx b/apps/web/src/routes/guides/$.tsx index 9dd564d6f..f226f9eff 100644 --- a/apps/web/src/routes/guides/$.tsx +++ b/apps/web/src/routes/guides/$.tsx @@ -7,6 +7,7 @@ import { localizeGuideFrontmatter } from "@/lib/i18n/content-copy"; import { m } from "@/paraglide/messages.js"; export const Route = createFileRoute("/guides/$")({ + codeSplitGroupings: [["loader"], ["component"]], loader: ({ params }) => { const slug = (params._splat ?? "").split("/").filter(Boolean); const page = getGuidePage(slug); diff --git a/apps/web/src/routes/guides/index.tsx b/apps/web/src/routes/guides/index.tsx index 2a421a54a..0583340d6 100644 --- a/apps/web/src/routes/guides/index.tsx +++ b/apps/web/src/routes/guides/index.tsx @@ -7,6 +7,7 @@ import { localizeGuideFrontmatter } from "@/lib/i18n/content-copy"; import { m } from "@/paraglide/messages.js"; export const Route = createFileRoute("/guides/")({ + codeSplitGroupings: [["loader"], ["component"]], loader: () => { const page = getGuidePage([]); if (!page) throw notFound(); diff --git a/apps/web/src/routes/stack_.$comboSlug.tsx b/apps/web/src/routes/stack_.$comboSlug.tsx index be3f7ba1f..0ebe9cb77 100644 --- a/apps/web/src/routes/stack_.$comboSlug.tsx +++ b/apps/web/src/routes/stack_.$comboSlug.tsx @@ -13,7 +13,6 @@ import { canonicalUrl, getEcosystemOgImage, } from "@/lib/seo/seo"; -import { getStackPage } from "@/lib/stack-pages/source"; function stackPageJsonLd(page: GeneratedStackPage) { const url = canonicalUrl(`/stack/${page.slug}`); @@ -47,7 +46,8 @@ function stackPageJsonLd(page: GeneratedStackPage) { } export const Route = createFileRoute("/stack_/$comboSlug")({ - loader: ({ params }) => { + loader: async ({ params }) => { + const { getStackPage } = await import("@/lib/stack-pages/source"); const page = getStackPage(params.comboSlug); if (!page) throw notFound(); return page; diff --git a/apps/web/src/routes/templates.tsx b/apps/web/src/routes/templates.tsx index 047ceaa9d..77e838787 100644 --- a/apps/web/src/routes/templates.tsx +++ b/apps/web/src/routes/templates.tsx @@ -12,7 +12,6 @@ import { SITE_URL, canonicalUrl, } from "@/lib/seo/seo"; -import { getPublishedStackPages } from "@/lib/stack-pages/source"; const TEMPLATE_IMAGE = canonicalUrl("/search-media/stack-decisions-1200x630.png"); const ECOSYSTEM_ORDER = ["typescript", "python", "go", "rust"] as const; @@ -58,12 +57,17 @@ function templateIndexJsonLd(pages: GeneratedStackPage[]) { } export const Route = createFileRoute("/templates")({ - head: () => { + loader: async () => { + const { getPublishedStackPages } = await import("@/lib/stack-pages/source"); + return getPublishedStackPages().sort( + (left, right) => right.priority - left.priority || left.title.localeCompare(right.title), + ); + }, + head: ({ loaderData: pages = [] }) => { const title = `Fullstack Starter Templates | ${SITE_NAME}`; const description = "Browse compatibility-checked fullstack starter templates for TanStack Start, Next.js, Hono, FastAPI, Go, Rust, databases, ORMs, auth, and API layers."; const url = canonicalUrl("/templates"); - const pages = getPublishedStackPages(); return { meta: [ @@ -138,9 +142,7 @@ function TemplateCard({ page, ordinal }: { page: GeneratedStackPage; ordinal: nu } function TemplatesPage() { - const pages = getPublishedStackPages().sort( - (left, right) => right.priority - left.priority || left.title.localeCompare(right.title), - ); + const pages = Route.useLoaderData(); return ( <> diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 66580c70e..6b0600efb 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1,7 +1,5 @@ @import "tailwindcss"; @import "tw-animate-css"; -/* Caveat (Google Fonts) is linked from the document head in __root.tsx, - a CSS @import here would block rendering on an external fetch. */ @custom-variant dark (&:where(.dark, .dark *)); @@ -21,14 +19,14 @@ @font-face { font-family: "Geist Sans"; - src: url("/fonts/Geist-Variable.woff2") format("woff2"); + src: url("../assets/fonts/Geist-Variable.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } @font-face { font-family: "Geist Mono"; - src: url("/fonts/GeistMono-Variable.woff2") format("woff2"); + src: url("../assets/fonts/GeistMono-Variable.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } diff --git a/apps/web/test/e2e/test-helpers.ts b/apps/web/test/e2e/test-helpers.ts index ae6590c95..cfa5e5e86 100644 --- a/apps/web/test/e2e/test-helpers.ts +++ b/apps/web/test/e2e/test-helpers.ts @@ -7,6 +7,7 @@ export const commandOutput = (page: Page): Locator => visibleTestId(page, "comma export async function gotoAppPage(page: Page, url: string) { await page.goto(url, { waitUntil: "domcontentloaded" }); + await expect(page.locator("html[data-hydrated]")).toBeAttached({ timeout: 30_000 }); } export async function openBuilder(page: Page) { diff --git a/apps/web/test/interface/performance-entry-assets.test.ts b/apps/web/test/interface/performance-entry-assets.test.ts new file mode 100644 index 000000000..a6e616162 --- /dev/null +++ b/apps/web/test/interface/performance-entry-assets.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; + +import { collectEntryAssets } from "@scripts/performance-entry-assets.mjs"; + +test("budgets real entries and shared static dependencies without charging lazy routes", () => { + const assets = collectEntryAssets({ + tiny: { file: "assets/index-tiny.js" }, + client: { + isEntry: true, + file: "assets/app.js", + imports: ["react", "router"], + css: ["assets/app.css"], + dynamicImports: ["builder"], + }, + react: { file: "assets/react.js", imports: ["shared"] }, + router: { file: "assets/router.js", imports: ["shared"] }, + shared: { file: "assets/shared.js", css: ["assets/app.css", "assets/shared.css"] }, + builder: { file: "assets/builder.js" }, + }); + + expect(assets.js).toEqual([ + "assets/app.js", + "assets/react.js", + "assets/router.js", + "assets/shared.js", + ]); + expect(assets.css).toEqual(["assets/app.css", "assets/shared.css"]); +}); + +test("fails closed when a static entry dependency is missing", () => { + expect(() => + collectEntryAssets({ + client: { isEntry: true, file: "assets/app.js", imports: ["missing"] }, + }), + ).toThrow("Missing static dependency"); + expect(() => collectEntryAssets({})).toThrow("no JavaScript entry"); +}); diff --git a/apps/web/vite-plugins/project-stats.ts b/apps/web/vite-plugins/project-stats.ts new file mode 100644 index 000000000..7224777d8 --- /dev/null +++ b/apps/web/vite-plugins/project-stats.ts @@ -0,0 +1,32 @@ +import type { Plugin } from "vite"; + +import { fileURLToPath } from "node:url"; + +import * as homeFeatures from "#web/lib/project/home-display-data"; +import * as projectStats from "#web/lib/project/project-stats"; + +const staticModules = new Map( + ( + [ + ["project-stats", projectStats], + ["home-display-data", homeFeatures], + ] as const + ).map( + ([name, values]) => + [fileURLToPath(new URL(`../src/lib/project/${name}.ts`, import.meta.url)), values] as const, + ), +); + +// Marketing pages need display data, not the catalogs and results used to calculate it. +export function projectStatsPlugin(): Plugin { + return { + name: "better-fullstack:project-stats", + load(id) { + const values = staticModules.get(id); + if (!values) return; + return Object.entries(values) + .map(([name, value]) => `export const ${name} = ${JSON.stringify(value)};`) + .join("\n"); + }, + }; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a1390394c..ab3658404 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,7 @@ import type { ShikiTransformer } from "shiki"; import { contentMetaPlugin } from "#vite-plugins/content-meta"; +import { projectStatsPlugin } from "#vite-plugins/project-stats"; import { paraglideCompilerOptions } from "#web-root/paraglide.config"; import { remarkExtractToc } from "#web/lib/docs/remark-extract-toc"; import { remarkNpmTabs } from "#web/lib/docs/remark-npm-tabs"; @@ -110,6 +111,7 @@ export default defineConfig({ __BFS_DEPLOYED_GIT_HEAD__: JSON.stringify(deployedGitHead), }, build: { + manifest: true, sourcemap: false, minify: "esbuild", rollupOptions: { @@ -148,6 +150,7 @@ export default defineConfig({ }, plugins: [ contentMetaPlugin(), + projectStatsPlugin(), ssrMdxLoaderAliasPlugin(), ssrTemplateGeneratorAliasPlugin(), paraglideVitePlugin(paraglideCompilerOptions),