Problemas comunes: monorepos, deps que cambian y CJS
Tres focos recurrentes de dolor con optimizeDeps: dependencias enlazadas en un monorepo que Vite no empaqueta por defecto, dependencias que cambian bajo tus pies y disparan reoptimizaciones con recarga, y dependencias solo-CJS cuyos named exports fallan. El diagnóstico y la salida limpia de cada una.
El noventa por ciento del tiempo optimizeDeps es invisible y no piensas en él. El diez por ciento restante se concentra en tres escenarios que producen síntomas desconcertantes: un paquete del workspace que no se actualiza, una página que se recarga sola a media sesión, un named import que de pronto es undefined. Cada uno tiene una explicación nítida en cuanto conoces el modelo del pre-bundling, y una salida estándar. Este nivel los reúne para que reconozcas el patrón antes de perder la tarde persiguiendo un fantasma.
- Diagnosticar las dependencias enlazadas de un monorepo que Vite no pre-empaqueta.
- Entender por qué una dependencia que cambia dispara una reoptimización con recarga.
- Resolver el fallo de named exports de las dependencias solo-CJS.
- Conectar cada síntoma con la parte del modelo de pre-bundling que lo explica.
Dependencias enlazadas en un monorepo
Por defecto, Vite no pre-empaqueta las dependencias enlazadas de un workspace. Las reconoce porque, en lugar de ser una copia normal en node_modules, son un enlace simbólico que apunta a otra carpeta del repo. La razón de dejarlas fuera es sensata: un paquete enlazado suele ser código tuyo que estás editando, y quieres HMR sobre él, no un artefacto congelado en la caché. Servido como fuente, cada guardado se refleja al instante, que es justo lo que esperas de tu propio código.
El problema aparece cuando ese paquete enlazado no es ESM limpio. Si se publica en CommonJS, el navegador tropezará con require. Si estalla en muchos módulos internos, vuelve la cascada de peticiones. Y si arrastra sus propias subdependencias CJS, esas también llegan crudas al navegador, con los dos males del nivel inicial reapareciendo por la puerta de atrás.
export default defineConfig({
optimizeDeps: {
// Fuerza el pre-bundling de un paquete enlazado no-ESM
include: ["@acme/ui", "@acme/ui > alguna-dep-cjs"],
},
});
La salida es meter el paquete enlazado en optimizeDeps.include, que fuerza su pre-bundling pese a estar enlazado. El precio es el esperable: al empaquetarlo, se congela en la caché, y tus ediciones dejan de refrescarse en caliente. Es el compromiso de siempre —fuente para editar, bundle para consumir—.
Por eso la mejor solución de fondo suele ser otra: publicar tus paquetes internos como ESM limpio, con un exports que apunte a módulos ESM. Así Vite puede servirlos como fuente sin que nada se rompa y sin tocar include en absoluto. El patrón de internal packages que viste en el track —consumir el fuente directamente dentro del monorepo— encaja aquí como anillo al dedo: si el paquete ya es ESM, el dilema entre HMR y compatibilidad sencillamente no llega a plantearse.
Para saber en qué caso estás, abre el package.json del paquete enlazado y mira su type y su exports. Un type de módulo con exportaciones que apuntan a archivos ESM es un paquete que Vite puede servir como fuente sin problemas; un paquete sin ese campo, o cuyos exports apuntan a artefactos CommonJS, es el que te obligará a include. Ese vistazo de diez segundos suele ahorrar media hora de prueba y error a ciegas.
Ese chequeo del package.json es, en el fondo, el mismo método que recorre todo este nivel: en lugar de probar remedios, lee la evidencia que explica el síntoma. El formato en que se publica un paquete determina cómo lo trata el pre-bundling, así que mirarlo primero convierte un misterio en una decisión informada.
Y si el paquete es tuyo, tienes la palanca definitiva: cambiar su publicación a ESM elimina el problema en la fuente, para ti y para cualquiera que lo consuma. Arreglar el paquete, cuando puedes, siempre gana a parchear a cada consumidor uno por uno.
Dependencias que cambian bajo tus pies
Este dolor tiene dos sabores. El primero es el descubrimiento en caliente. Si una dependencia solo se alcanza por un import() que el escaneo inicial no rastreó, Vite no la conoce hasta que el navegador la ejecuta por primera vez. En ese momento se detiene, la pre-empaqueta sobre la marcha, cambia la versión en su URL y recarga la página entera. Lo verás anunciado en la consola con un mensaje inconfundible.
# El aviso tipico en consola cuando el escaneo se quedo corto
[vite] new dependencies optimized: chart.js
[vite] optimized dependencies changed. reloading
Es molesto pero se cura solo; para evitar la recarga, se anticipa la dependencia en optimizeDeps.include, como viste en el nivel anterior. El segundo sabor es una dependencia que cambias tú mientras desarrollas: la parcheas, la enlazas, o subes su versión a mano.
flowchart TD A[Sesion de dev en marcha] --> B[El navegador ejecuta un import dinamico] B -->|estaba pre-empaquetada| D[Se sirve al instante] B -->|no lo estaba| E[Vite reoptimiza sobre la marcha] E --> F[Cambia la version y recarga la pagina] style D fill:#a6e3a1,color:#11111b style F fill:#f38ba8,color:#11111b
Según el cambio, la huella de la caché lo capturará o no —lo viste al estudiar la invalidación— y cuando no lo capture, vite --force es el reset. La regla mental es simple: si tras cambiar una dependencia sigues viendo comportamiento viejo, sospecha de la caché antes que de tu código, porque casi siempre es ella la que se ha quedado atrás.
El patrón práctico es tratar cada recarga sorpresa como una pista, no como una molestia: apunta qué dependencia la provocó y, si se repite en tu día a día, muévela a include para pagar su coste una vez al arrancar en lugar de una vez por cada sesión en que la toques.
La recarga que dispara el descubrimiento en caliente asusta la primera vez, pero es el sistema cumpliendo su promesa: prefiere una recarga puntual y correcta antes que servirte en silencio una dependencia sin optimizar. Si esas recargas se vuelven frecuentes en tu flujo, no las silencies, escúchalas: te están señalando con exactitud qué dependencias anticipar en include.
Dependencias solo-CJS problemáticas
El tercer foco es el más traicionero porque el síntoma parece un bug de tu código. Importas algo con nombre desde un paquete CommonJS y sale undefined, o Vite protesta con un error de que el paquete no exporta ese nombre. Miras tu import diez veces, está bien escrito, y aun así no funciona.
// Falla: el analisis estatico no vio este named export
import { algunaFuncion } from "paquete-cjs-raro";
// Funciona: importa el default y desestructura del objeto completo
import pkg from "paquete-cjs-raro";
const { algunaFuncion } = pkg;
La causa está en la naturaleza de CommonJS. Un módulo CJS no declara sus exportaciones de forma estática: las asigna a module.exports en tiempo de ejecución, a veces dentro de un bucle o bajo condiciones. Para ofrecerte named imports, el bundler analiza el paquete estáticamente con un lexer y reconstruye la lista de nombres. Cuando un paquete construye sus exportaciones de forma demasiado dinámica, ese análisis no las ve, y el named import que escribes sencillamente no existe para el bundler.
Hay tres salidas, de más a menos recomendable según el caso. La primera, importar el default y desestructurar, como arriba: siempre funciona porque el objeto completo sí llega íntegro. La segunda, meter el paquete en optimizeDeps.include, que a veces basta para que el tratamiento de interoperabilidad reconstruya bien los nombres. La tercera, declarar explícitamente que el paquete necesita interop, para los casos rebeldes que ni el análisis ni el include resuelven solos.
export default defineConfig({
optimizeDeps: {
include: ["paquete-cjs-raro"],
// Marca explicita para los paquetes CJS que el lexer no resuelve solo
needsInterop: ["paquete-cjs-raro"],
},
});
Ante la duda con un paquete CJS, el default import es el refugio seguro. CommonJS tiene un único punto de salida real —module.exports—, y ese objeto siempre llega íntegro como el default en ESM. Los named exports son una comodidad que el bundler reconstruye por análisis, y por tanto son lo que puede fallar. Importar el default y desestructurar renuncia a esa comodidad a cambio de una garantía: lo que exista en el paquete, ahí estará, lo viera el lexer o no.
Estas tres salidas no compiten entre sí: el default es el remedio inmediato cuando quieres seguir trabajando ya, include es el arreglo estructural para tu equipo, y needsInterop es la red para el paquete que se resiste a todo lo demás. Elegir cuál usar es cuestión de cuánto quieres invertir y de cuán rebelde sea el paquete que tienes delante.
Conviene además entender que esta fricción es transitoria por naturaleza. Cada año que pasa, más paquetes se publican en ESM nativo o en formato dual, y la superficie de dependencias solo-CJS problemáticas se encoge. Pero mientras el registro arrastre dos décadas de CommonJS heredado, seguirás cruzándote con algún paquete rebelde, y saber que el default siempre llega íntegro es el salvavidas que nunca falla.
El método: del síntoma a la suposición rota
Los tres síntomas y sus remedios caben en una sola tabla de diagnóstico, que vale la pena memorizar porque convierte tres problemas dispersos en un único procedimiento:
| Síntoma | Suposición del modelo que se rompe | Salida |
|---|---|---|
| Un cambio no se refleja | las dependencias son estables | exclude, include o --force |
| Recarga sorpresa a media sesión | todo es descubrible estáticamente | anticipar la dep en include |
Named export ausente o undefined |
las exportaciones son analizables | default más desestructuración, o include |
Antes de aplicar la tabla, conviene un chequeo previo de encuadre: ¿es esto siquiera un problema de optimizeDeps? Un fallo que solo aparece con vite y se desvanece sobre un build de producción apunta al pre-bundling; uno que persiste en ambos apunta a tu código, a un plugin o a la externalización de SSR, y la tabla no aplica. Situar el problema en el régimen correcto es el paso cero, y ahorra el error clásico de forzar la caché una y otra vez cuando el fallo vivía en otra parte.
Monorepo enlazado
Paquete del workspace no-ESM que Vite sirve como fuente y el navegador no digiere. Salida: include, o publicarlo como ESM.
Dep que cambia
Descubrimiento en caliente con recarga, o un cambio que la huella no vio. Salida: anticipar con include, o --force.
CJS con named exports
El lexer no reconstruyó los nombres de un paquete CommonJS dinámico. Salida: default más desestructuración, o needsInterop.
La virtud de la tabla no es memorizar remedios, sino invertir el flujo del diagnóstico: en lugar de partir de la herramienta —probemos --force a ver— partes del síntoma y lo mapeas a la suposición rota, y de ahí sale la herramienta única que corresponde. Ese orden —síntoma, suposición, herramienta— es lo que impide el manoseo de probar include, luego exclude, luego --force hasta que algo funcione sin entender por qué.
Si observas los tres escenarios de este nivel con algo de distancia, verás que no son tres problemas distintos, sino tres manifestaciones de una única grieta. El pre-bundling es una heurística que construye un modelo de tu grafo de dependencias, y ese modelo descansa sobre tres suposiciones: que puede descubrir estáticamente todo lo que importas, que las dependencias son artefactos estables que no editas, y que las exportaciones de un paquete son analizables sin ejecutarlo. Cada uno de los tres dolores es exactamente un punto donde el mundo real viola una de esas suposiciones. La dependencia enlazada viola la de estabilidad: es código vivo que editas, y por eso Vite duda entre servirla como fuente o congelarla en un bundle. El import dinámico tardío viola la de descubrimiento estático: el escaneo no ejecuta tu código, así que una dependencia escondida tras una condición en tiempo de ejecución es invisible hasta que ocurre. Y el paquete solo-CJS con exportaciones dinámicas viola la de analizabilidad: construye su interfaz en tiempo de ejecución, fuera del alcance de cualquier lexer estático. Ver esta unidad es lo que convierte el diagnóstico de una lotería en un método. Ante un síntoma raro de optimizeDeps, el ingeniero senior no prueba soluciones al azar copiadas de un issue de GitHub; se pregunta cuál de las tres suposiciones del modelo está rota en este caso, porque de esa respuesta se deduce el remedio sin ensayo y error. ¿El síntoma es que un cambio no se refleja? Suposición de estabilidad rota: es una dependencia viva, piensa en exclude, en include o en --force. ¿El síntoma es una recarga sorpresa a media sesión? Suposición de descubrimiento rota: hay un import que el escaneo no vio, adelántalo con include. ¿El síntoma es un named export ausente? Suposición de analizabilidad rota: es CJS dinámico, cae al default o fuerza el interop. El pre-bundling no falla al azar; falla en las costuras exactas donde su modelo del mundo se aparta del mundo real, y aprender a nombrar esas costuras es aprender a depurarlo de una vez y para siempre, en lugar de reaprenderlo con dolor en cada proyecto nuevo. Ese salto —de coleccionar remedios a entender el modelo que los une— es, en el fondo, todo lo que separa a quien copia soluciones de quien las deduce.
- Enlaza un paquete CommonJS en un workspace, impórtalo y observa el fallo; arréglalo con
optimizeDeps.includey comprueba el coste en HMR. - Esconde una dependencia tras un
import()disparado por un clic y provoca la reoptimización con recarga; luego elimínala anticipándola eninclude. - Busca en tus dependencias un paquete CJS con exportaciones dinámicas y reproduce el named import roto.
- Cúralo de las tres formas —default más desestructuración,
include, yneedsInterop— y decide cuál es la más limpia para tu caso. - Por cada uno de los tres síntomas, escribe en una frase qué suposición del modelo de pre-bundling se rompió, para fijar el método de diagnóstico.