Los límites de HMR y el fallback a full reload
Un límite HMR es el módulo que absorbe una actualización y detiene su propagación. Cuando la búsqueda no encuentra ninguno antes de la raíz, o topa con un ciclo o un cambio inaceptable, Vite se rinde y recarga la página entera. Cuándo, por qué, y cómo diseñar para evitarlo.
Cada actualización de HMR es la búsqueda de un muro. Un límite es el módulo que dice detente aquí, yo me encargo. Si la búsqueda llega al punto de entrada sin encontrar ninguno, no hay dónde aplicar el cambio de forma quirúrgica, y Vite hace lo único seguro: recargar todo. Saber qué constituye un buen límite y qué fuerza una recarga es lo que te permite escribir código que se mantiene caliente edición tras edición.
- Definir un límite HMR como el módulo que absorbe y detiene la propagación.
- Enumerar las causas concretas del fallback a full reload.
- Entender por qué mezclar exportaciones rompe un límite de framework.
- Diseñar módulos para que la propagación termine pronto y localmente.
Qué es un límite
Un límite es cualquier módulo que se acepta a sí mismo o que es aceptado por todos sus importadores para la dependencia que cambió. Es donde termina el conjunto de módulos a actualizar: por debajo, se descartan y se reimportan; por encima, quedan intactos. Todo el arte del buen HMR consiste en tener límites cerca de las hojas del grafo. Los frameworks colocan un límite en casi cada módulo de componente de forma automática, y por eso editar un componente suele actualizar solo el subárbol de ese componente.
Conviene distinguir dos formas de ser límite. Un módulo puede ser autoaceptante —llamó a accept sin dependencias y absorbe sus propias actualizaciones— o puede ser aceptado por sus importadores —cada uno de ellos lo nombró como dependencia en su accept—. En el primer caso el límite es el propio módulo cambiado; en el segundo, son sus padres.
// forma 1: el modulo es su propio limite
import.meta.hot.accept();
// forma 2: cada importador acepta al hijo como dependencia
import.meta.hot.accept("./hijo.js", (nuevoHijo) => reaplicar(nuevoHijo));
Un matiz fácil de pasar por alto: para que un módulo sea límite por la vía de sus importadores, todos ellos deben aceptarlo. Si cinco archivos lo importan y solo cuatro lo aceptan, el quinto sigue siendo una vía por la que la onda sube, y basta esa única fuga para que la actualización no quede contenida. Por eso el caso robusto es el autoaceptante: no depende de que nadie más coopere.
Visto así, el conjunto de módulos que una edición reejecuta tiene una frontera inferior y una superior nítidas: por abajo, la hoja que cambiaste; por arriba, el límite que la absorbe. Todo lo intermedio se descarta y se vuelve a pedir con la marca de tiempo fresca; todo lo que queda por encima del límite ni se entera. Diseñar buen HMR es, literalmente, controlar dónde cae esa frontera superior.
Cuándo se cae a full reload
El fallback a recarga completa no es aleatorio; tiene disparadores concretos y reconocibles.
Callejón sin salida
La propagación llega a un módulo sin importadores —un punto de entrada— que no acepta. No hay nadie aguas arriba que pueda absorber el cambio.
Cadena que no acepta
Ningún módulo del camino, desde la hoja hasta la raíz, se autoacepta ni es aceptado por sus importadores. La onda sube sin encontrar muro.
Dependencia circular
Hay un ciclo en la cadena de propagación. Vite no puede garantizar un orden de reejecución consistente, así que recarga por seguridad.
Cambio no intercambiable
Tocas la config de Vite, un archivo .env, el index.html o un archivo que un plugin marca como recarga-obligatoria. El servidor puede incluso reiniciarse.
A esos se suma el caso de un invalidate que escala hasta la raíz sin que ningún ancestro recoja la actualización: el módulo aceptó de forma optimista, se rindió, y nadie por encima pudo con el cambio.
Merece la pena interiorizar que estos disparadores se reducen a una sola condición: la búsqueda de un límite no terminó dentro del grafo antes de agotar los caminos hacia arriba. El callejón sin salida, la cadena que no acepta y el invalidate que escala son la misma situación vista en distintos puntos; el ciclo y el cambio no intercambiable son casos donde Vite ni siquiera intenta la búsqueda, porque sabe de antemano que no puede garantizar un resultado consistente.
El caso del reinicio del servidor merece distinguirse del full reload normal. Cuando editas vite.config.ts, un archivo .env o un tsconfig que afecta a la resolución, no basta con recargar la página: el propio proceso de Vite tiene que releer su configuración y reconstruir sus entornos, así que reinicia el servidor entero y el navegador se reconecta después. Es el nivel más pesado de la jerarquía —módulo caliente, página recargada, servidor reiniciado— y por eso conviene mantener la config lo más estable posible durante una sesión de trabajo.
flowchart TD cambio[Modulo editado] --> q1[Es self accepting] q1 -->|si| fin[Limite actualiza local] q1 -->|no| q2[Algun importador lo acepta] q2 -->|si| fin q2 -->|no| q3[Tiene importadores] q3 -->|si| sube[Sube al importador y repite] q3 -->|no| full[Full reload] sube --> q1 style fin fill:#a6e3a1,color:#11111b style full fill:#f38ba8,color:#11111b
El caso de las exportaciones mezcladas
La recarga completa autoinfligida más común: un módulo que un framework trataría como límite de componente pero que además exporta algo que no es un componente —una constante, un hook, una utilidad—. React Fast Refresh solo puede autoaceptar un módulo cuyos exports sean todos componentes. En cuanto añades una constante junto al componente, el módulo deja de ser un límite de refresco válido: invalida, y la actualización se propaga a sus importadores, con frecuencia hasta una recarga completa.
// mal: el archivo deja de ser limite de refresco por el export extra
export const COLOR = "azul";
export function Boton() { /* ... */ }
// bien: la constante en su modulo, el componente solo en el suyo
// colores.js -> export const COLOR = "azul";
// Boton.jsx -> export function Boton() { /* ... */ }
El principio general es que un límite debe poder reejecutarse sin efectos visibles para sus importadores salvo a través de la interfaz aceptada. Mezclar responsabilidades en un módulo ensancha lo que un cambio puede afectar, y eso empuja el límite hacia arriba. La cura es quirúrgica: mantén los componentes en archivos que solo exporten componentes, y mueve constantes y hooks a sus propios módulos.
El ecosistema codificó esta regla en un linter. react-refresh/only-export-components marca cualquier archivo de componente que exporte algo que no lo sea, precisamente para atrapar el problema antes de que te sorprenda en el navegador. Cuando un archivo mezcla de forma legítima un componente con una constante que le pertenece, la salida es moverla a un módulo hermano y volver a importarla: el componente recupera su condición de límite y la constante viaja por su propia arista del grafo.
Un caso emparentado y traicionero son los archivos barril —esos index que reexportan medio directorio—. Si un componente importa constantes desde un barril que también reexporta componentes y utilidades, un cambio en cualquier miembro del barril puede invalidar el módulo entero y arrastrar a todos sus consumidores a una recarga. Los barriles son cómodos para escribir imports, pero para el HMR son cuellos de botella: concentran muchas aristas en un nodo, y ese nodo se vuelve un punto por el que casi cualquier cambio termina propagándose lejos.
Otra variante del mismo problema es el componente exportado por defecto de forma anónima. Sin un nombre estable, el runtime de refresco no puede casar la instancia vieja con la nueva y, en el mejor caso, pierde el estado; en el peor, degrada a recarga. Ponerle nombre a cada componente, aun cuando se exporte por defecto, es la diferencia entre un refresco que conserva y uno que reinicia.
Nueve de cada diez veces, la respuesta es exportaciones mezcladas. Si editar un único componente recarga la página entera, revisa si su archivo exporta algo que no sea un componente —una constante, un tipo con valor, un helper—. Divide el archivo para que el módulo del componente exporte solo el componente, y la misma edición pasará a ser una actualización local.
Diseñar para que la propagación termine pronto
La ergonomía del HMR no es suerte: es una propiedad emergente de la topología de tu grafo. Unas pocas disciplinas la garantizan.
- Un componente por archivo, o al menos solo componentes por archivo, para que el módulo pueda ser un límite autoaceptante.
- Aísla los singletons con efectos detrás de módulos que se autoaceptan y limpian en
dispose, para que su reejecución no filtre estado. - Deja constantes y config en módulos hoja que algo acepte, en lugar de mezclarlas con componentes.
- Rompe los ciclos de importación: dañan el HMR además de la claridad, porque fuerzan la recarga.
// un singleton con efecto, aislado tras un limite que se limpia
let socket = crearSocket();
if (import.meta.hot) {
import.meta.hot.accept();
import.meta.hot.dispose(() => socket.close());
}
Hay una heurística que resume todo esto: cuanto más cerca de las hojas del grafo estén tus límites, más local será cada actualización. Un límite en cada componente hoja significa que la mayoría de tus ediciones tocan un solo archivo y actualizan un solo subárbol; un grafo sin límites hasta muy arriba significa que casi cualquier edición sube hasta la raíz y recarga. El HMR no premia el código más corto ni el más ingenioso, sino el mejor factorizado: módulos con una responsabilidad, fronteras nítidas y sin ciclos. Esa es, no por casualidad, la misma factorización que pedirías por cualquier otra razón de ingeniería.
// antipatron para HMR: un barril que reexporta de todo
// index.js
export * from "./Boton.jsx";
export * from "./constantes.js";
export * from "./utils.js";
Cuando dudes de por qué una edición recarga, no lo adivines: obsérvalo. El overlay y la terminal de Vite nombran el módulo que forzó la recarga y el motivo, y con el modo --debug puedes ver la cadena de propagación completa. La misma disciplina de instrumentar en vez de suponer que aplicas al resto del toolchain vale aquí: el grafo real casi nunca es el que tienes en la cabeza.
En Vite 8 hay un matiz por entorno: una misma edición puede actualizar en caliente el entorno de cliente pero forzar recarga en el de SSR, o al revés. La Environment API ejecuta la propagación por separado en cada entorno, y el hook hotUpdate de un plugin decide, entorno por entorno, si actualiza o recarga.
Algunas recargas completas son correctas y deseables. Editar la tabla de rutas, un proveedor global de contexto o la configuración cambia de verdad la identidad de la aplicación, y un arranque limpio es la respuesta honesta. La meta no es erradicar todo full reload, sino asegurarte de que los que ocurren son los legítimos y no accidentes de un módulo mal dividido.
Entender esto invierte cómo lees cada recarga. El HMR es una optimización optimista y de mejor esfuerzo: intenta parchear un programa en marcha en su sitio, algo intrínsecamente inseguro, porque estás mutando un sistema vivo cuyo estado actual podría ser incompatible con el código nuevo. La genialidad del diseño de Vite es que, cuando el parche no puede demostrarse local —ningún límite antes de la raíz, un ciclo, un cambio incompatible, un invalidate que llega arriba—, no adivina ni arriesga un runtime corrupto: cae a la única operación siempre correcta, un reinicio limpio desde el punto de entrada. Así, cada full reload es el sistema eligiendo correctitud sobre inmediatez en el instante exacto en que la inmediatez se volvió insegura. Esto reencuadra el trabajo del desarrollador: no peleas contra las recargas, diseñas los límites para que el camino rápido y seguro sea el común. Y aquí está la coincidencia hermosa: la disciplina de límites que logra un HMR impecable —una responsabilidad por módulo, sin exports mezclados, sin ciclos, efectos detrás de dispose— es la misma disciplina que vuelve el código legible y testeable. La calidad del HMR y la calidad del código resultan ser el mismo eje medido dos veces: un grafo fácil de parchear en caliente es un grafo fácil de razonar. Optimizar para que tus ediciones se queden calientes no es hacer trampas al toolchain; es, literalmente, mejorar la arquitectura de tus módulos, y el HMR te lo mide en tiempo real cada vez que guardas.
- Fuerza un full reload a propósito añadiendo un export que no sea componente a un archivo de componente; lee el motivo en la terminal o el overlay de error.
- Divide el archivo y comprueba cómo la misma edición pasa a ser una actualización local.
- Introduce una importación circular entre dos módulos que aceptan y observa la recarga que provoca.
- Edita
vite.config.tsy contrasta el reinicio del servidor frente a una actualización caliente normal. - En un proyecto con SSR, edita un módulo compartido y observa qué entorno se actualiza en caliente y cuál recarga entero.
- Activa
react-refresh/only-export-componentsen tu linter y comprueba que marca los archivos con exports mezclados antes de llegar al navegador.