app/dashboard/page.tsx ejecuta 5 llamadas independientes de forma secuencial.
Con p99 de latencia de 200ms por llamada, esto suma ~1000ms de tiempo de servidor innecesario.
La regla async-waterfall exige Promise.all para trabajo independiente.
Además, la sesión se comprueba antes que un guardado barato (!params.teamId).
export default async function DashboardPage({ params }: { params: { teamId: string } }) { // ① Cada await bloquea al siguiente const session = await getSession(); if (!session) redirect("/login"); const team = await getTeam(params.teamId); const user = await getUser(session.userId); const events = await getRecentEvents(params.teamId); const metrics = await getMetrics(params.teamId); const billing = await getBillingStatus(params.teamId); // Total: ~5 × RTT de red }
export default async function DashboardPage({ params }: { params: { teamId: string } }) { // ① Condición barata primero (sin await) if (!params.teamId) notFound(); // ② Auth primero (gating) const session = await getSession(); if (!session) redirect("/login"); // ③ Paralelo — trabajo independiente const [team, user, events, metrics, billing] = await Promise.all([ getTeam(params.teamId), getUser(session.userId), getRecentEvents(params.teamId), getMetrics(params.teamId), getBillingStatus(params.teamId), ]); // Total: max(RTT) ~200ms vs ~1000ms }
- LCP −800ms estimado
- TTFB mejorado
- Regla:
async-parallel
components/ChartPanel.tsx importa d3 completo (~500KB), moment (~67KB) y lodash (~70KB).
Solo se usan fragmentos mínimos de cada librería. Además importa 4 tipos de chart cuando solo se renderiza uno.
Reglas: bundle-direct-imports (evitar barrels), bundle-dynamic (lazy load componentes pesados).
import { LineChart, BarChart, PieChart, ScatterChart } from "@/components/charts"; import * as d3 from "d3"; // ~500KB import moment from "moment"; // ~67KB import _ from "lodash"; // ~70KB export function ChartPanel({ data, type }) { const formatted = moment(data.ts) .format("DD/MM/YYYY"); const sorted = _.sortBy(data.events, "timestamp"); }
import dynamic from "next/dynamic"; // Charts: lazy por tipo const charts = { line: dynamic(() => import( "@/components/charts/LineChart")), bar: dynamic(() => import( "@/components/charts/BarChart")), }; // Sin moment: nativo const fmt = new Intl.DateTimeFormat("es", { day:"2-digit",month:"2-digit",year:"numeric" }); // Sin lodash: nativo const sorted = [...data.events] .sort((a,b) => a.timestamp-b.timestamp); export function ChartPanel({ data, type }) { const Chart = charts[type]; return <Chart data={data} />; }
- Bundle −600KB (67% reducción)
- TBT mejorado significativamente
- Regla:
bundle-direct-imports,bundle-dynamic
EventsTable.tsx tiene 3 bugs de re-render:
(1) suscripción a todo el store, (2) sort con useEffect + setState en vez de derivar en render,
(3) RowActions definido dentro del cuerpo del componente padre — nuevo tipo React en cada render, destruye reconciliación.
export function EventsTable() { // ① store completo = re-render en cualquier cambio const store = useStore((s) => s); const [sorted, setSorted] = useState([]); // ② derivado via useEffect (incorrecto) useEffect(() => { setSorted(store.events.sort(...)); }, [store]); return sorted.map((evt) => ( <EventRow key={evt.id} event={evt} store={store} /> )); } // ③ componente DENTRO de otro function EventRow({ event, store }) { const RowActions = () => ( // Nuevo tipo cada render <button onClick={() => store.deleteEvent(event.id)}> Eliminar</button> ); return <div><RowActions /></div>; }
export function EventsTable() { // ① Selector granular const events = useStore((s) => s.events); const deleteEvent = useStore( (s) => s.deleteEvent); // ② Derivado en render, no en useEffect const sorted = useMemo( () => [...events].sort( (a,b) => b.timestamp - a.timestamp), [events]); return sorted.map((evt) => ( <EventRow key={evt.id} event={evt} onDelete={deleteEvent} /> )); } // ③ Componente FUERA, memoizado const EventRow = memo(function EventRow({ event, onDelete }) { return ( <div> <button onClick={() => onDelete(event.id)}> Eliminar</button> </div> ); });
- INP −60% estimado
- Re-renders eliminados en mutaciones de store no relacionadas
- Reglas:
rerender-selector,rerender-derive-in-render,rerender-no-component-in-component
app/dashboard/layout.tsx pasa el objeto user a tres Client Components distintos.
RSC serializa los datos por cada prop boundary → el payload user viaja 3 veces en el RSC Payload.
Además falta React.cache() en getUser() para deduplicar la query de BD cuando se llama desde múltiples RSC del mismo render.
export default async function DashboardLayout( { children } ) { const user = await getUser(); // sin cache() return ( <> <Sidebar user={user} /> {/* serializa */} <Header user={user} /> {/* serializa */} <Topbar user={user} /> {/* serializa */} {children} </> ); }
// lib/queries.ts export const getUser = cache(async () => { return db.user.findUnique(...); }); // layout.tsx export default async function DashboardLayout( { children } ) { const user = await getUser(); // deduplicado return ( // UserProvider serializa user una sola vez <UserProvider user={user}> <Sidebar /> <Header /> <Topbar /> {children} </UserProvider> ); }
- RSC Payload −67% para datos de usuario
- Query de BD deduplicada por request
- Reglas:
server-rsc-cache,server-minimize-props
hooks/useScrollDepth.ts añade el listener sin { passive: true }, lo que bloquea el hilo de composición
en cada evento de scroll. El navegador espera saber si el handler llama a preventDefault() antes de pintar el siguiente frame.
Además cada componente que use el hook añade su propio listener en lugar de compartir uno.
export function useScrollDepth() { useEffect(() => { window.addEventListener("scroll", handler); return () => window.removeEventListener( "scroll", handler); }, []); } // Cada componente = nuevo listener
// Singleton a nivel módulo const scrollSubs = new Set<() => void>(); let installed = false; function ensureListener() { if (installed) return; window.addEventListener("scroll", () => scrollSubs.forEach(fn => fn()), { passive: true }); // ← clave installed = true; } export function useScrollDepth() { const [depth, setDepth] = useState(0); useEffect(() => { ensureListener(); const handler = () => setDepth( Math.round(window.scrollY / document.body.scrollHeight * 100)); scrollSubs.add(handler); return () => scrollSubs.delete(handler); }, []); }
- Scroll FPS mejorado
- Listener único en vez de N
- Regla:
client-passive-listener,client-deduplicate
| # | Archivo | Regla | Severidad | Métrica afectada | Acción |
|---|---|---|---|---|---|
| 1 | app/dashboard/page.tsx | async-parallel | Crítico | LCP, TTFB | Promise.all para 4 fetches independientes |
| 2 | app/dashboard/page.tsx | async-cheap-first | Crítico | TTFB | Mover !teamId antes del primer await |
| 3 | components/ChartPanel.tsx | bundle-direct-imports | Crítico | Bundle, TBT | Eliminar d3 completo, moment y lodash |
| 4 | components/ChartPanel.tsx | bundle-dynamic | Crítico | Bundle, LCP | dynamic() por tipo de chart |
| 5 | components/EventsTable.tsx | rerender-store-selector | Alto | INP | Selector granular en lugar de (s) => s |
| 6 | components/EventsTable.tsx | rerender-derive-in-render | Alto | INP | useMemo en render, eliminar useEffect |
| 7 | components/EventsTable.tsx | rerender-no-inline-component | Alto | INP, CLS | RowActions fuera del componente padre |
| 8 | app/dashboard/layout.tsx | server-rsc-cache | Medio | TTFB, Payload RSC | React.cache() + UserProvider único |
| 9 | hooks/useScrollDepth.ts | client-passive-listener | Medio | FPS scroll | { passive: true } + singleton subject |
- Sprint 1 (esta semana): Promise.all en DashboardPage + eliminar d3/moment/lodash de ChartPanel. Impacto directo en LCP y bundle.
- Sprint 1: Migrar imports de charts a dynamic() con Skeleton de carga. Medible con @next/bundle-analyzer.
- Sprint 2: Refactorizar EventsTable — selectores granulares Zustand, derivar sorted en useMemo, extraer EventRow con memo().
- Sprint 2: Añadir React.cache() a getUser() y UserProvider en layout. Verificar con React DevTools Profiler.
- Sprint 3: Passive scroll listener + shared singleton. Activar next.config.js optimizePackageImports para el resto de libs.
- Continuo: Añadir
@next/bundle-analyzeral pipeline CI para detectar regresiones de bundle en PRs.