<?xml version='1.0' encoding='UTF-8'?><?xml-stylesheet href="http://www.blogger.com/styles/atom.css" type="text/css"?><feed xmlns='http://www.w3.org/2005/Atom' xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/' xmlns:blogger='http://schemas.google.com/blogger/2008' xmlns:georss='http://www.georss.org/georss' xmlns:gd="http://schemas.google.com/g/2005" xmlns:thr='http://purl.org/syndication/thread/1.0'><id>tag:blogger.com,1999:blog-3279603830404251843</id><updated>2026-11-10T09:00:00.124-05:00</updated><category term="Errores y soluciones"/><category term="Linux"/><category term="Windows"/><category term="TypeScript"/><category term="DevOps"/><category term="Git"/><category term="JavaScript"/><category term="React"/><category term="Next.js"/><category term="Node.js"/><category term="Java"/><category term="Centos"/><category term="Prisma"/><category term="Alfresco"/><category term="Bases de datos"/><category term="CSS"/><category term="EJB"/><category term="Python"/><category term="Testing"/><category term="DOTCMIS"/><category term="Docker"/><category term="JBoss"/><category term="App Web"/><category term="CMIS"/><category term="CMIS - Workbench"/><category term="Codificación"/><category term="Desarrollo de Software"/><category term="Eclipse"/><category term="Errores"/><category term="Generación de código"/><category term="Innovación Tecnológica"/><category term="Inteligencia Artificial"/><category term="Postgresql"/><category term="PowerShell"/><category term="Programación"/><category term="Tecnología"/><category term="Ubuntu"/><category term="recomendaciones personalizadas"/><category term="AI"/><category term="AI entretenimiento"/><category term="AI hogar"/><category term="Alexa"/><category term="Angular 11"/><category term="Aprendizaje Automático"/><category term="Aprendizaje Online"/><category term="Asistentes AI"/><category term="Asistentes Inteligentes"/><category term="Automatización del Hogar"/><category term="Ayuda digital"/><category term="CMS"/><category term="Chatbots"/><category term="Chrome"/><category term="Cirugía"/><category term="Comparación"/><category term="Compras Online"/><category term="Comunicación"/><category term="Comunidad"/><category term="Control de Versiones"/><category term="Dart"/><category term="Desarrollo Multiplataforma"/><category term="Desarrollo de Aplicaciones Móviles"/><category term="Dialogflow"/><category term="Disney+"/><category term="Dispositivos Conectados"/><category term="Drupal"/><category term="Drupal 8"/><category term="Educación"/><category term="Electrodomésticos Inteligentes"/><category term="Energías Renovables"/><category term="Experiencia de Usuario IA"/><category term="Facilidad de Uso"/><category term="Flutter"/><category term="Futuro"/><category term="Futuro AI"/><category term="GStreamer"/><category term="Google Assistant"/><category term="Herramientas de IA"/><category term="Hogar Inteligente"/><category term="Hulu"/><category term="IA cotidiana"/><category term="IA en Retail"/><category term="IA en la vida diaria"/><category term="IA salud bienestar"/><category term="Implementación de IA"/><category term="Información"/><category term="Innovación"/><category term="Inteligencia Artificial en Comercio"/><category term="Log4j"/><category term="Medio Ambiente"/><category term="Netflix"/><category term="Pidgin"/><category term="React Native"/><category term="Rendimiento"/><category term="Rhythmbox"/><category term="SLF4j"/><category term="Salud"/><category term="Salud AI"/><category term="Servicio al Cliente"/><category term="Siri"/><category term="Sistema de Recomendación"/><category term="Soporte"/><category term="Spirng Roo"/><category term="Subversion"/><category term="Tecnología AI"/><category term="Tecnología Doméstica"/><category term="Tecnología Verde"/><category term="Tecnología de Compras"/><category term="Tecnología en el Aula"/><category term="Telemedicina"/><category term="Tendencias"/><category term="Tendencias E-Commerce"/><category term="TensorFlow"/><category term="Transformación Digital"/><category term="Tutoriales Técnicos"/><category term="Vida Cotidiana"/><category term="Vida moderna"/><category term="WebService"/><category term="algoritmos de IA"/><category term="apps fitness inteligentes"/><category term="asistentes salud digitales"/><category term="binge-watching"/><category term="colaboración en equipo"/><category term="desarrollo bajo código"/><category term="desarrollo web"/><category term="diseño de interfaz de usuario"/><category term="eficiencia en codificación"/><category term="elecciones de entretenimiento"/><category term="frontend"/><category term="inteligencia artificial cuidado personal."/><category term="monitor sueño IA"/><category term="programación fácil"/><category term="recordatorios hidratación IA"/><category term="series de ciencia ficción"/><category term="servicios Angular"/><category term="software simplificado"/><category term="streaming"/><category term="tecnología bienestar personalizado"/><category term="tendencias de visualización"/><category term="tutorial Angular"/><title type='text'>Tutoriales Técnicos</title><subtitle type='html'>¡Explora y ríe con Tutoriales Técnicos! Aquí, desenredamos el desarrollo de software con guías amenas y te mantenemos al tanto de lo último en tecnología. Aprende, diviértete y lleva tus habilidades digitales al próximo nivel. ¡Tu aventura tecnológica comienza aquí!</subtitle><link rel='http://schemas.google.com/g/2005#feed' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/posts/default'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default?redirect=false'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/'/><link rel='hub' href='http://pubsubhubbub.appspot.com/'/><link rel='next' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default?start-index=26&amp;max-results=25&amp;redirect=false'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><generator version='7.00' uri='http://www.blogger.com'>Blogger</generator><openSearch:totalResults>110</openSearch:totalResults><openSearch:startIndex>1</openSearch:startIndex><openSearch:itemsPerPage>25</openSearch:itemsPerPage><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7105104861337529573</id><published>2026-11-10T09:00:00.000-05:00</published><updated>2026-11-10T09:00:00.120-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>React Hook Form: trigger() dice Required con el campo lleno</title><content type='html'>&lt;p&gt;Escribes la contraseña en un paso de un asistente, el input la muestra, pero al llamar a &lt;code&gt;trigger()&lt;/code&gt; el paso no avanza y el error dice que el campo es obligatorio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const ok = await form.trigger();   // false
form.formState.errors.password;
// { type: &quot;invalid_type&quot;, message: &quot;Required&quot; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El texto exacto depende del esquema de validación; &lt;code&gt;Required&lt;/code&gt; es el mensaje por defecto de Zod 3 para un valor ausente. La causa más probable cuando hay varias llamadas a &lt;code&gt;useForm()&lt;/code&gt; y una función compartida que dibuja los campos es que el campo esté conectado al formulario equivocado. Este artículo explica por qué el campo &quot;se ve&quot; correcto, cómo comprobarlo y cómo evitar que vuelva a pasar.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Cada llamada a &lt;code&gt;useForm()&lt;/code&gt; crea un formulario independiente. Según la documentación de React Hook Form, el objeto &lt;code&gt;control&lt;/code&gt; que devuelve contiene los métodos para registrar componentes, y se pasa a &lt;code&gt;Controller&lt;/code&gt;, &lt;code&gt;useController&lt;/code&gt;, &lt;code&gt;useWatch&lt;/code&gt;, &lt;code&gt;useFieldArray&lt;/code&gt; o &lt;code&gt;useFormState&lt;/code&gt; para que compartan la misma instancia de formulario. Dicho de otro modo: el campo se registra en el formulario cuyo &lt;code&gt;control&lt;/code&gt; le entregas.&lt;/p&gt;
&lt;p&gt;Este es el patrón con el fallo. La función recibe &lt;code&gt;form&lt;/code&gt;, pero el &lt;code&gt;Controller&lt;/code&gt; usa el control de otro formulario que estaba al alcance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function renderCampo(form: any, name: string, label: string) {
  return (
    &amp;lt;Controller
      name={name}
      control={otroForm.control}   // otroForm: any; debería ser form.control
      render={({ field }) =&amp;gt; (
        &amp;lt;label&amp;gt;
          {label}
          &amp;lt;input {...field} value={field.value ?? &#39;&#39;} /&amp;gt;
        &amp;lt;/label&amp;gt;
      )}
    /&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Con esto ocurre lo siguiente:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;El input lee y escribe el valor en &lt;code&gt;otroForm&lt;/code&gt;, así que se ve lleno y reacciona al teclado.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;form&lt;/code&gt; nunca llega a conocer el campo. Con un resolver de esquema (Zod, Yup...), &lt;code&gt;form.trigger()&lt;/code&gt; valida los valores de &lt;code&gt;form&lt;/code&gt;, donde la contraseña no existe, y devuelve el error del esquema.&lt;/li&gt;
  &lt;li&gt;Con reglas integradas (&lt;code&gt;rules={{ required: &#39;...&#39; }}&lt;/code&gt;) el efecto es peor, porque es silencioso: en una reproducción, &lt;code&gt;trigger()&lt;/code&gt; devolvió &lt;code&gt;true&lt;/code&gt; con el campo vacío, ya que &lt;code&gt;form&lt;/code&gt; no tenía ningún campo registrado que validar.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TypeScript tampoco lo detectó porque los formularios implicados estaban tipados como &lt;code&gt;any&lt;/code&gt;. Al compilar un caso de prueba con &lt;code&gt;tsc&lt;/code&gt; en modo estricto, el error solo apareció cuando el formulario equivocado estaba tipado y tenía una forma distinta (TS2322 al usar un nombre de campo que no existe en ese tipo). Si era &lt;code&gt;any&lt;/code&gt;, o si compartía el mismo nombre de campo, no hubo ningún aviso.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma el diagnóstico con la API pública.&lt;/strong&gt; Con el campo escrito, mira los valores del formulario que valida:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;console.log(form.getValues());   // {} en vez de { password: &#39;...&#39; }&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si el valor aparece en &lt;code&gt;getValues()&lt;/code&gt; de otro formulario, el campo está conectado a ese otro.&lt;/p&gt;
    &lt;p&gt;Como ayuda temporal de depuración también puedes listar los campos registrados con &lt;code&gt;Object.keys(form.control._fields)&lt;/code&gt;. Es una propiedad interna: la documentación indica no acceder a las propiedades de &lt;code&gt;control&lt;/code&gt;, y puede cambiar entre versiones. Úsala solo para depurar y bórrala después.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Usa siempre el &lt;code&gt;control&lt;/code&gt; del formulario que recibes.&lt;/strong&gt; En la función compartida, cambia &lt;code&gt;otroForm.control&lt;/code&gt; por &lt;code&gt;form.control&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Elimina la posibilidad de equivocarte.&lt;/strong&gt; Saca el campo del componente padre y haz que reciba &lt;code&gt;control&lt;/code&gt; como parámetro. Así no hay ningún otro formulario al alcance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { Controller, type Control, type FieldValues, type Path } from &#39;react-hook-form&#39;;

type CampoProps&amp;lt;T extends FieldValues&amp;gt; = {
  control: Control&amp;lt;T&amp;gt;;
  name: Path&amp;lt;T&amp;gt;;
  label: string;
};

export function Campo&amp;lt;T extends FieldValues&amp;gt;({ control, name, label }: CampoProps&amp;lt;T&amp;gt;) {
  return (
    &amp;lt;Controller
      name={name}
      control={control}
      render={({ field }) =&amp;gt; (
        &amp;lt;label&amp;gt;
          {label}
          &amp;lt;input {...field} value={field.value ?? &#39;&#39;} /&amp;gt;
        &amp;lt;/label&amp;gt;
      )}
    /&amp;gt;
  );
}

// Uso en el paso de la contraseña:
// &amp;lt;Campo control={formClave.control} name=&quot;password&quot; label=&quot;Contraseña&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Tipa el formulario y evita &lt;code&gt;any&lt;/code&gt;.&lt;/strong&gt; Si prefieres pasar el formulario completo, usa &lt;code&gt;UseFormReturn&amp;lt;T&amp;gt;&lt;/code&gt; en lugar de &lt;code&gt;any&lt;/code&gt;. Ayuda, pero no es suficiente por sí solo, como se vio arriba; por eso conviene el paso anterior.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba el resultado de &lt;code&gt;trigger()&lt;/code&gt; antes de avanzar.&lt;/strong&gt; La función devuelve una promesa con un booleano, y acepta un nombre de campo o una lista de nombres para validar solo el paso actual:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const avanzar = async () =&amp;gt; {
  const valido = await formClave.trigger([&#39;password&#39;]);
  if (valido) siguientePaso();
};&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Escribe en el campo y comprueba dos cosas en la consola:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;console.log(formClave.getValues());      // { password: &#39;...&#39; }
console.log(await formClave.trigger());  // true&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deja ahora el campo vacío y repite: &lt;code&gt;trigger()&lt;/code&gt; debe devolver &lt;code&gt;false&lt;/code&gt; y el paso no debe avanzar. Si muestras el error en pantalla, aparecerá el mensaje del esquema. Esa segunda comprobación es la que detecta la variante silenciosa con reglas integradas. Si ya tienes pruebas con Testing Library, añade una que escriba en el campo, llame a &lt;code&gt;trigger()&lt;/code&gt; y exija &lt;code&gt;true&lt;/code&gt;, y otra que lo deje vacío y exija &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Mismo fallo con &lt;code&gt;register&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Si en la función compartida se usa &lt;code&gt;{...otroForm.register(&#39;password&#39;)}&lt;/code&gt;, el campo queda registrado en el otro formulario y el síntoma es el mismo.&lt;/p&gt;
&lt;h3&gt;Otros hooks que reciben &lt;code&gt;control&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;useWatch&lt;/code&gt;, &lt;code&gt;useFieldArray&lt;/code&gt; y &lt;code&gt;useFormState&lt;/code&gt; también reciben &lt;code&gt;control&lt;/code&gt;. Si les pasas el de otro formulario, se conectan a ese formulario, normalmente sin lanzar ningún error.&lt;/p&gt;
&lt;h3&gt;FormProvider&lt;/h3&gt;
&lt;p&gt;Dentro de un &lt;code&gt;FormProvider&lt;/code&gt; la propiedad &lt;code&gt;control&lt;/code&gt; es opcional y los componentes toman la del contexto. Si usas un proveedor por paso, comprueba que el proveedor más cercano a cada campo es el del paso correcto.&lt;/p&gt;
&lt;h3&gt;El mensaje cambia con el esquema&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Required&lt;/code&gt; es el texto de Zod 3 para un valor ausente. Con otra versión o librería verás otro mensaje, pero la causa es la misma: el formulario validado no contiene el campo.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Cada &lt;code&gt;useForm()&lt;/code&gt; es un formulario independiente, y un campo pertenece al formulario cuyo &lt;code&gt;control&lt;/code&gt; recibe su &lt;code&gt;Controller&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Un campo conectado al formulario equivocado se ve y se edita con normalidad, pero &lt;code&gt;trigger()&lt;/code&gt; del formulario correcto no lo encuentra.&lt;/li&gt;
  &lt;li&gt;Con un resolver de esquema el síntoma es &lt;code&gt;Required&lt;/code&gt;; con reglas integradas puede ser un &lt;code&gt;true&lt;/code&gt; silencioso con el campo vacío.&lt;/li&gt;
  &lt;li&gt;Pasa &lt;code&gt;control&lt;/code&gt; como parámetro a un componente de módulo en lugar de cerrar sobre otro formulario, y evita &lt;code&gt;any&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Diagnostica con &lt;code&gt;getValues()&lt;/code&gt;; las propiedades internas de &lt;code&gt;control&lt;/code&gt; son solo para depurar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://react-hook-form.com/docs/useform/control&quot;&gt;React Hook Form: &lt;code&gt;control&lt;/code&gt; (qué es y a qué APIs se pasa)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://react-hook-form.com/docs/usecontroller&quot;&gt;React Hook Form: &lt;code&gt;useController&lt;/code&gt; (propiedad &lt;code&gt;control&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://react-hook-form.com/docs/useform/trigger&quot;&gt;React Hook Form: &lt;code&gt;trigger&lt;/code&gt; (validación por campo y valor devuelto)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7105104861337529573/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/react-hook-form-trigger-dice-required.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7105104861337529573'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7105104861337529573'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/react-hook-form-trigger-dice-required.html' title='React Hook Form: trigger() dice Required con el campo lleno'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-4587141035509368071</id><published>2026-11-09T15:00:00.000-05:00</published><updated>2026-11-09T15:00:00.175-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="JavaScript"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><title type='text'>React: key={index} hace que una fila pise a otra</title><content type='html'>&lt;p&gt;Tienes un formulario con filas dinámicas (las líneas de un pedido, por ejemplo). Al elegir un producto en una fila, otra fila cambia o se queda vacía. A veces, al quitar una fila, la siguiente hereda el texto que habías escrito en la anterior. React no muestra ningún error ni aviso: el síntoma solo se ve en el estado.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Esperado: &quot;cafe&quot; en la fila 1 y &quot;te&quot; en la fila 2
[{ productId: &quot;cafe&quot; }, { productId: &quot;te&quot; }]

// Real: la segunda selección desaparece
[{ productId: &quot;cafe&quot; }, { productId: &quot;&quot; }]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Detrás suele haber tres cosas combinadas: usar el índice del arreglo como &lt;code&gt;key&lt;/code&gt;, handlers que capturan &lt;code&gt;idx&lt;/code&gt; y el arreglo &lt;code&gt;items&lt;/code&gt; del render, y actualizaciones de estado calculadas a partir de ese arreglo capturado. Este es el patrón típico con el fallo (&lt;code&gt;Fila&lt;/code&gt; es el componente que dibuja cada fila):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function Pedido() {
  const [items, setItems] = useState([
    { productId: &#39;&#39;, precio: null },
    { productId: &#39;&#39;, precio: null },
  ]);

  const seleccionar = async (idx, productId) =&amp;gt; {
    const precio = await obtenerPrecio(productId);
    setItems(items.map((it, i) =&amp;gt; (i === idx ? { productId, precio } : it)));
  };
  const quitar = (idx) =&amp;gt; setItems(items.filter((_, i) =&amp;gt; i !== idx));

  return (
    &amp;lt;ul&amp;gt;
      {items.map((item, idx) =&amp;gt; (
        &amp;lt;Fila
          key={idx}
          item={item}
          onSelect={(p) =&amp;gt; seleccionar(idx, p)}
          onRemove={() =&amp;gt; quitar(idx)}
        /&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;h3&gt;1. El índice no identifica a la fila&lt;/h3&gt;
&lt;p&gt;React usa la &lt;code&gt;key&lt;/code&gt; para saber qué elemento es cuál entre un render y el siguiente, y conserva el estado de un componente mientras se siga renderizando en la misma posición. Con &lt;code&gt;key={idx}&lt;/code&gt; la identidad es el hueco, no la fila. Si quitas la primera fila, la segunda pasa a ocupar el índice 0 y React reutiliza el componente de ese hueco, con todo su estado interno (texto escrito, inputs no controlados, selects propios). La documentación de React advierte que el índice como clave da errores sutiles cuando los elementos se insertan, se eliminan o se reordenan, y que las claves no deben cambiar.&lt;/p&gt;
&lt;h3&gt;2. Los cierres ven una instantánea del estado&lt;/h3&gt;
&lt;p&gt;El estado de React es una instantánea por render: un handler creado en un render ve los valores de ese render. En el código anterior, &lt;code&gt;seleccionar&lt;/code&gt; espera una respuesta asíncrona y después ejecuta &lt;code&gt;setItems(items.map(...))&lt;/code&gt; con el &lt;code&gt;items&lt;/code&gt; de cuando se creó el handler. Si entre medias el usuario eligió un producto en otra fila, esa copia vieja de la lista lo borra: gana la última promesa en resolverse. En una reproducción, la fila 0 (respuesta lenta) y la fila 1 (respuesta rápida) acabaron con la fila 1 vacía.&lt;/p&gt;
&lt;h3&gt;3. Quitar con estado capturado&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;quitar&lt;/code&gt; tiene el mismo problema. Si una consulta estaba en vuelo cuando se quitó la fila, al resolverse vuelve a escribir la lista antigua y la fila quitada reaparece (también se reproduce con una prueba).&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Genera un id al crear la fila, no al renderizar.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const crearItem = () =&amp;gt; ({
  id: crypto.randomUUID(),
  productId: &#39;&#39;,
  precio: null,
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La documentación de React indica que, para datos generados localmente, se use un contador incremental, &lt;code&gt;crypto.randomUUID()&lt;/code&gt; o un paquete como &lt;code&gt;uuid&lt;/code&gt; en el momento de crear el elemento. Lo que no se debe hacer es generar la clave durante el render (por ejemplo &lt;code&gt;key={Math.random()}&lt;/code&gt;). Según &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID&quot;&gt;MDN&lt;/a&gt;, &lt;code&gt;randomUUID()&lt;/code&gt; solo está disponible en contextos seguros (HTTPS); si tu aplicación no lo cumple, usa un contador.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Usa ese id como clave y crea el estado inicial una sola vez.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const [items, setItems] = useState(() =&amp;gt; [crearItem(), crearItem()]);

// ...
&amp;lt;Fila key={item.id} item={item} /* ... */ /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Actualiza por id con funciones de actualización.&lt;/strong&gt; React recibe una función que calcula el siguiente estado a partir del último estado pendiente, no del que capturó tu handler:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const actualizar = (id, cambios) =&amp;gt;
  setItems((prev) =&amp;gt;
    prev.map((it) =&amp;gt; (it.id === id ? { ...it, ...cambios } : it)),
  );
const quitar = (id) =&amp;gt; setItems((prev) =&amp;gt; prev.filter((it) =&amp;gt; it.id !== id));
const agregar = () =&amp;gt; setItems((prev) =&amp;gt; [...prev, crearItem()]);&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Estas funciones deben ser puras: calculan el resultado y nada más.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pasa el id, no el índice, a los callbacks, también en lo asíncrono.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const seleccionar = async (id, productId) =&amp;gt; {
  const precio = await obtenerPrecio(productId);
  actualizar(id, { productId, precio });
};

// En el JSX:
// onSelect={(p) =&amp;gt; seleccionar(item.id, p)}
// onRemove={() =&amp;gt; quitar(item.id)}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si la fila se quitó mientras la consulta estaba en vuelo, ninguna fila coincide con ese id y la actualización no cambia nada, en lugar de escribir sobre otra fila.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Prueba a mano con tres filas: escribe una nota distinta en cada una y quita la primera; cada nota debe seguir con su fila. Después elige productos en dos filas seguidas con la red limitada desde las herramientas del navegador: ambas selecciones deben conservarse. Repite todo dentro de &lt;code&gt;&amp;lt;StrictMode&amp;gt;&lt;/code&gt;, que en desarrollo ejecuta dos veces los inicializadores y las funciones de actualización para detectar impurezas.&lt;/p&gt;
&lt;p&gt;Para dejarlo cubierto con Testing Library, suponiendo que cada &lt;code&gt;Fila&lt;/code&gt; muestra un input con la etiqueta &quot;Nota&quot;, que guarda su texto en un estado propio, y un botón &quot;Quitar&quot;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { fireEvent, render, screen } from &#39;@testing-library/react&#39;;

it(&#39;la nota viaja con su fila al quitar la primera&#39;, () =&amp;gt; {
  render(&amp;lt;Pedido /&amp;gt;);
  const [notaA, notaB] = screen.getAllByLabelText(&#39;Nota&#39;);
  fireEvent.change(notaA, { target: { value: &#39;A&#39; } });
  fireEvent.change(notaB, { target: { value: &#39;B&#39; } });

  fireEvent.click(screen.getAllByText(&#39;Quitar&#39;)[0]);

  const restante = screen.getByLabelText(&#39;Nota&#39;) as HTMLInputElement;
  expect(restante.value).toBe(&#39;B&#39;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Con &lt;code&gt;key={idx}&lt;/code&gt; esta prueba falla (la fila restante muestra &lt;code&gt;A&lt;/code&gt;); con un id estable pasa.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;key={Math.random()}&lt;/code&gt; o un uuid dentro del JSX&lt;/h3&gt;
&lt;p&gt;La clave cambia en cada render, así que React desmonta y vuelve a montar todas las filas: se pierde el foco y lo que el usuario estaba escribiendo. Genera el id al crear el elemento y guárdalo en el estado.&lt;/p&gt;
&lt;h3&gt;Cuándo el índice sí es aceptable&lt;/h3&gt;
&lt;p&gt;La documentación lo admite en un caso muy concreto: una lista cuyos elementos nunca se reordenan, como las líneas de un poema fijo. Una lista donde el usuario agrega o quita filas no es ese caso.&lt;/p&gt;
&lt;h3&gt;Filas que ya vienen de una API&lt;/h3&gt;
&lt;p&gt;Usa el id que asigna la base de datos y genera uno propio solo para las filas nuevas que aún no se guardaron.&lt;/p&gt;
&lt;h3&gt;React Hook Form con useFieldArray&lt;/h3&gt;
&lt;p&gt;La documentación de &lt;code&gt;useFieldArray&lt;/code&gt; indica usar &lt;code&gt;field.id&lt;/code&gt;, y no el índice, como clave de cada fila.&lt;/p&gt;
&lt;h3&gt;Cambiar la clave a propósito&lt;/h3&gt;
&lt;p&gt;Cambiar la &lt;code&gt;key&lt;/code&gt; de un componente reinicia su estado. Es una herramienta útil (por ejemplo, para resetear un formulario), pero conviene usarla solo cuando ese reinicio es lo que buscas.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;key={idx}&lt;/code&gt; hace que el estado interno de una fila pertenezca a la posición, no a la fila, y se mezcle al quitar o reordenar.&lt;/li&gt;
  &lt;li&gt;Un handler que usa &lt;code&gt;items&lt;/code&gt; capturado y escribe después de un &lt;code&gt;await&lt;/code&gt; pisa los cambios hechos en otras filas.&lt;/li&gt;
  &lt;li&gt;Asigna un id estable al crear la fila (&lt;code&gt;crypto.randomUUID()&lt;/code&gt; o un contador) y úsalo como &lt;code&gt;key&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Actualiza con &lt;code&gt;setItems((prev) =&amp;gt; ...)&lt;/code&gt; y localiza la fila por id, no por índice.&lt;/li&gt;
  &lt;li&gt;Verifica quitando la primera fila y eligiendo productos en dos filas con respuesta lenta.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://react.dev/learn/rendering-lists&quot;&gt;React: Rendering Lists (reglas de las claves y uso del índice)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://react.dev/learn/queueing-a-series-of-state-updates&quot;&gt;React: Queueing a Series of State Updates (funciones de actualización)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://react.dev/learn/preserving-and-resetting-state&quot;&gt;React: Preserving and Resetting State (estado y posición en el árbol)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/4587141035509368071/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/react-keyindex-hace-que-una-fila-pise.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4587141035509368071'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4587141035509368071'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/react-keyindex-hace-que-una-fila-pise.html' title='React: key={index} hace que una fila pise a otra'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7987343715892768931</id><published>2026-11-09T09:00:00.000-05:00</published><updated>2026-11-09T09:00:00.114-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="CSS"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><title type='text'>shadcn/ui Table: sticky y scroll horizontal no funcionan</title><content type='html'>&lt;p&gt;Usas el componente &lt;code&gt;Table&lt;/code&gt; de shadcn/ui dentro de un contenedor propio para añadir dos mejoras: un encabezado fijo (&lt;code&gt;position: sticky&lt;/code&gt;) y una barra de desplazamiento horizontal arriba de la tabla. Ninguna funciona: la barra superior nunca aparece, aunque la tabla sea mucho más ancha que el contenedor, y el encabezado se va con el resto de las filas al desplazarte. No hay errores en la consola. La causa está en el HTML que genera el propio componente:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;mi-contenedor&quot;&amp;gt;                                  &amp;lt;!-- tu contenedor --&amp;gt;
  &amp;lt;div data-slot=&quot;table-container&quot;
       class=&quot;relative w-full overflow-x-auto&quot;&amp;gt;               &amp;lt;!-- lo agrega Table --&amp;gt;
    &amp;lt;table&amp;gt;...&amp;lt;/table&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;El componente &lt;code&gt;Table&lt;/code&gt; que shadcn/ui copia a tu proyecto no renderiza solo un &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt;: lo envuelve en un &lt;code&gt;div&lt;/code&gt; con su propio desplazamiento. En el registro actual de shadcn/ui, ese &lt;code&gt;div&lt;/code&gt; usa &lt;code&gt;relative w-full overflow-x-auto&lt;/code&gt; y el atributo &lt;code&gt;data-slot=&quot;table-container&quot;&lt;/code&gt;; en proyectos generados con versiones anteriores, la clase era &lt;code&gt;overflow-auto&lt;/code&gt;. Revisa tu archivo &lt;code&gt;components/ui/table.tsx&lt;/code&gt; para ver cuál tienes; los dos casos se comportan igual para este problema.&lt;/p&gt;
&lt;p&gt;Ese &lt;code&gt;div&lt;/code&gt; interno se convierte en el contenedor de desplazamiento, y esto rompe las dos mejoras:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;El scroll horizontal superior.&lt;/strong&gt; El contenido se desborda dentro del &lt;code&gt;div&lt;/code&gt; interno, no dentro de tu contenedor. En la prueba, el contenedor externo medía lo mismo de ancho visible que de contenido (&lt;code&gt;scrollWidth&lt;/code&gt; igual a &lt;code&gt;clientWidth&lt;/code&gt;), mientras que el &lt;code&gt;div&lt;/code&gt; interno tenía 1246 px de contenido en 478 px visibles. Si calculas el desbordamiento sobre tu contenedor, siempre concluirás que no hay nada que desplazar.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El encabezado sticky.&lt;/strong&gt; Según MDN, un elemento con &lt;code&gt;position: sticky&lt;/code&gt; se ancla al ancestro más cercano que tiene un mecanismo de desplazamiento. Con &lt;code&gt;overflow-x: auto&lt;/code&gt;, el eje vertical del &lt;code&gt;div&lt;/code&gt; interno también pasa a comportarse como &lt;code&gt;auto&lt;/code&gt; (MDN: si un eje es &lt;code&gt;visible&lt;/code&gt; y el otro no, el &lt;code&gt;visible&lt;/code&gt; se comporta como &lt;code&gt;auto&lt;/code&gt;). Pero ese &lt;code&gt;div&lt;/code&gt; no tiene altura máxima, así que nunca se desplaza verticalmente. El encabezado queda anclado a un elemento que no se mueve, y el que se desplaza es tu contenedor externo. En la prueba, tras desplazar el externo 150 px, el encabezado quedó a 149 px por encima de su borde superior: se había ido con las filas.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Neutraliza el &lt;code&gt;div&lt;/code&gt; interno desde tu contenedor.&lt;/strong&gt; Con una variante arbitraria de Tailwind, apuntas al hijo directo y le quitas el desplazamiento:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div className=&quot;max-h-[70vh] overflow-auto [&amp;amp;&amp;gt;div]:overflow-visible&quot;&amp;gt;
  &amp;lt;Table&amp;gt;...&amp;lt;/Table&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Según la documentación de Tailwind, las variantes arbitrarias son cadenas con el selector entre corchetes, donde &lt;code&gt;&amp;amp;&lt;/code&gt; representa al elemento actual. Con Tailwind 4 se generó &lt;code&gt;.\[\&amp;amp;\&amp;gt;div\]\:overflow-visible &amp;gt; div { overflow: visible; }&lt;/code&gt;. Su especificidad (&lt;code&gt;0-1-1&lt;/code&gt;: una clase y un tipo) es mayor que la de &lt;code&gt;.overflow-x-auto&lt;/code&gt; o &lt;code&gt;.overflow-auto&lt;/code&gt; (&lt;code&gt;0-1-0&lt;/code&gt;), así que gana sin importar el orden de las reglas.&lt;/p&gt;
    &lt;p&gt;Como el archivo &lt;code&gt;components/ui/table.tsx&lt;/code&gt; vive en tu proyecto, también puedes editarlo y quitar el &lt;code&gt;div&lt;/code&gt; envoltorio. La variante arbitraria tiene la ventaja de no tocar el componente de la librería.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Deja un solo contenedor de desplazamiento y dale una altura máxima.&lt;/strong&gt; El &lt;code&gt;max-h-[70vh] overflow-auto&lt;/code&gt; del ejemplo anterior cumple las dos cosas. La altura máxima es imprescindible para el sticky: en la prueba, con un contenedor que tenía &lt;code&gt;overflow-x-auto&lt;/code&gt; pero ninguna altura máxima, el encabezado no se quedó fijo al desplazar la página (quedó 383 px por encima del borde superior de la ventana), porque seguía anclado a ese contenedor, que nunca se desplazaba en vertical.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Aplica el sticky a las celdas del encabezado.&lt;/strong&gt; MDN indica que hace falta un valor para &lt;code&gt;top&lt;/code&gt; (u otra propiedad de posición), y conviene dar un fondo para que las filas no se vean a través:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;TableHead className=&quot;sticky top-0 bg-background&quot;&amp;gt;Nombre&amp;lt;/TableHead&amp;gt;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea la barra de desplazamiento superior.&lt;/strong&gt; Un componente que mide el desbordamiento del contenedor, muestra una barra espejo arriba y sincroniza ambas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { useEffect, useRef, useState, type ReactNode } from &quot;react&quot;

export function ContenedorTabla({ children }: { children: ReactNode }) {
  const superiorRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null)
  const cuerpoRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null)
  const [ancho, setAncho] = useState(0)
  const [desborda, setDesborda] = useState(false)

  useEffect(() =&amp;gt; {
    const cuerpo = cuerpoRef.current
    if (!cuerpo) return

    const medir = () =&amp;gt; {
      setAncho(cuerpo.scrollWidth)
      setDesborda(cuerpo.scrollWidth &amp;gt; cuerpo.clientWidth)
    }

    medir()
    const observador = new ResizeObserver(medir)
    observador.observe(cuerpo)
    const tabla = cuerpo.querySelector(&quot;table&quot;)
    if (tabla) observador.observe(tabla)
    return () =&amp;gt; observador.disconnect()
  }, [])

  const sincronizar = (origen: HTMLDivElement | null, destino: HTMLDivElement | null) =&amp;gt; {
    if (origen &amp;amp;&amp;amp; destino &amp;amp;&amp;amp; destino.scrollLeft !== origen.scrollLeft) {
      destino.scrollLeft = origen.scrollLeft
    }
  }

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;div
        ref={superiorRef}
        hidden={!desborda}
        aria-hidden=&quot;true&quot;
        className=&quot;overflow-x-auto overflow-y-hidden&quot;
        onScroll={() =&amp;gt; sincronizar(superiorRef.current, cuerpoRef.current)}
      &amp;gt;
        &amp;lt;div style={{ width: ancho, height: 1 }} /&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div
        ref={cuerpoRef}
        role=&quot;region&quot;
        aria-label=&quot;Tabla de datos&quot;
        tabIndex={0}
        className=&quot;max-h-[70vh] overflow-auto [&amp;amp;&amp;gt;div]:overflow-visible&quot;
        onScroll={() =&amp;gt; sincronizar(cuerpoRef.current, superiorRef.current)}
      &amp;gt;
        {children}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La comprobación &lt;code&gt;destino.scrollLeft !== origen.scrollLeft&lt;/code&gt; evita que las dos barras se disparen eventos entre sí. El &lt;code&gt;role=&quot;region&quot;&lt;/code&gt; con &lt;code&gt;tabIndex={0}&lt;/code&gt; permite desplazar la tabla con el teclado.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Observa la tabla, no solo el contenedor.&lt;/strong&gt; Si los datos llegan después del montaje (una petición a una API, por ejemplo), la tabla se ensancha pero el contenedor mantiene su tamaño. &lt;code&gt;ResizeObserver&lt;/code&gt; notifica los cambios del tamaño del elemento observado, así que observar solo el contenedor no avisa. En la prueba, al añadir columnas tras el montaje, el observador del contenedor no se disparó ninguna vez y el de la tabla se disparó una vez, aunque el &lt;code&gt;scrollWidth&lt;/code&gt; del contenedor pasó a 1108 px con solo 478 px visibles. Por eso el código anterior observa ambos elementos.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Con la tabla cargada y más ancha que su contenedor, ejecuta en la consola:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const c = document.querySelector(&#39;[aria-label=&quot;Tabla de datos&quot;]&#39;)
c.scrollWidth &amp;gt; c.clientWidth                    // true: el contenedor sí ve el desbordamiento
getComputedStyle(c.firstElementChild).overflowX  // &quot;visible&quot;: el div interno ya no desplaza
c.scrollTop = 150
document.querySelector(&#39;thead th&#39;).getBoundingClientRect().top - c.getBoundingClientRect().top
// cerca de 0: el encabezado se quedó fijo arriba del contenedor&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En la prueba con el componente completo y datos que llegan 300 ms después del montaje, la barra superior estaba oculta al inicio, pasó a mostrarse con un ancho de 1246 px (igual al &lt;code&gt;scrollWidth&lt;/code&gt;), el desplazamiento de una barra se reflejó en la otra en ambos sentidos y el encabezado quedó a 0 px del borde superior del contenedor tras desplazar 150 px.&lt;/p&gt;
&lt;p&gt;Además, una prueba de componente con Vitest y Testing Library protege la parte que puede romperse sin que nadie lo note. Como jsdom no calcula el layout, la prueba simula las medidas y &lt;code&gt;ResizeObserver&lt;/code&gt;, y comprueba el contrato (la clase que neutraliza el &lt;code&gt;div&lt;/code&gt; interno y que se observe la tabla):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;it(&quot;neutraliza el contenedor con scroll propio de la tabla&quot;, () =&amp;gt; {
  render(&amp;lt;Ejemplo /&amp;gt;)
  const cuerpo = screen.getByRole(&quot;region&quot;, { name: &quot;Tabla de datos&quot; })
  expect(cuerpo.className).toContain(&quot;[&amp;amp;&amp;gt;div]:overflow-visible&quot;)
})

it(&quot;observa la tabla y no solo el contenedor&quot;, () =&amp;gt; {
  render(&amp;lt;Ejemplo /&amp;gt;)
  const observados = observadores.flatMap((o) =&amp;gt; o.objetivos)
  expect(observados).toContain(screen.getByRole(&quot;table&quot;))
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Aquí &lt;code&gt;Ejemplo&lt;/code&gt; renderiza &lt;code&gt;ContenedorTabla&lt;/code&gt; con una tabla pequeña, y &lt;code&gt;observadores&lt;/code&gt; es la lista de instancias del &lt;code&gt;ResizeObserver&lt;/code&gt; simulado, con los elementos que cada una observa. Sin la clase &lt;code&gt;[&amp;amp;&amp;gt;div]:overflow-visible&lt;/code&gt;, la primera prueba falla con &lt;code&gt;expected &#39;max-h-[70vh] overflow-auto&#39; to contain &#39;[&amp;amp;&amp;gt;div]:overflow-visible&#39;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;La tabla se monta después del contenedor&lt;/h3&gt;
&lt;p&gt;El efecto solo observa la tabla si ya existe cuando se ejecuta. Si la renderizas de forma condicional (por ejemplo, un indicador de carga en su lugar), mantén el &lt;code&gt;&amp;lt;Table&amp;gt;&lt;/code&gt; siempre montado y muestra el estado de carga dentro de &lt;code&gt;TableBody&lt;/code&gt;, o vuelve a ejecutar el efecto cuando aparezca (agregando esa condición a sus dependencias).&lt;/p&gt;
&lt;h3&gt;&quot;ResizeObserver loop completed with undelivered notifications&quot;&lt;/h3&gt;
&lt;p&gt;Es el mensaje que MDN documenta cuando la función de un &lt;code&gt;ResizeObserver&lt;/code&gt; cambia el tamaño de un elemento que sigue observando. Aquí la función solo actualiza la barra superior, que no se observa, así que no debería aparecer. Si te pasa en una variante del código, MDN sugiere posponer el cambio de tamaño con &lt;code&gt;requestAnimationFrame&lt;/code&gt; o evitar cambiar el tamaño cuando el elemento ya tiene el valor buscado.&lt;/p&gt;
&lt;h3&gt;Solo quieres el encabezado fijo&lt;/h3&gt;
&lt;p&gt;Si no necesitas la barra superior, los pasos 1 a 3 son suficientes: neutralizar el &lt;code&gt;div&lt;/code&gt; interno, usar un único contenedor con altura máxima y poner &lt;code&gt;sticky top-0&lt;/code&gt; con fondo en las celdas del encabezado.&lt;/p&gt;
&lt;h3&gt;El sticky sigue sin funcionar&lt;/h3&gt;
&lt;p&gt;Recorre los ancestros del encabezado y busca otro con &lt;code&gt;overflow&lt;/code&gt; distinto de &lt;code&gt;visible&lt;/code&gt; (en las herramientas de desarrollo, el panel de estilos calculados lo muestra). Cualquiera de ellos, además del contenedor deseado, se convierte en el ancla del sticky.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;El &lt;code&gt;Table&lt;/code&gt; de shadcn/ui envuelve la tabla en un &lt;code&gt;div&lt;/code&gt; con &lt;code&gt;overflow-x-auto&lt;/code&gt; (u &lt;code&gt;overflow-auto&lt;/code&gt; en versiones anteriores) que roba el desplazamiento a tu contenedor.&lt;/li&gt;
  &lt;li&gt;Un &lt;code&gt;position: sticky&lt;/code&gt; se ancla al ancestro más cercano con mecanismo de desplazamiento; si ese ancestro no se desplaza, el encabezado no se queda fijo.&lt;/li&gt;
  &lt;li&gt;Neutraliza el &lt;code&gt;div&lt;/code&gt; interno con &lt;code&gt;[&amp;amp;&amp;gt;div]:overflow-visible&lt;/code&gt; en tu contenedor y dale a este una altura máxima con &lt;code&gt;overflow-auto&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Calcula el desbordamiento sobre ese único contenedor y observa la tabla con &lt;code&gt;ResizeObserver&lt;/code&gt; para reaccionar cuando los datos llegan tarde.&lt;/li&gt;
  &lt;li&gt;Una prueba de componente que falle sin la clase evita que alguien restaure el comportamiento anterior sin darse cuenta.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/position&quot;&gt;MDN: &lt;code&gt;position&lt;/code&gt; (valor &lt;code&gt;sticky&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver&quot;&gt;MDN: &lt;code&gt;ResizeObserver&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://tailwindcss.com/docs/hover-focus-and-other-states&quot;&gt;Tailwind CSS: variantes arbitrarias (Hover, focus, and other states)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7987343715892768931/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/shadcnui-table-sticky-y-scroll.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7987343715892768931'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7987343715892768931'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/shadcnui-table-sticky-y-scroll.html' title='shadcn/ui Table: sticky y scroll horizontal no funcionan'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7015796266826778517</id><published>2026-11-08T15:00:00.000-05:00</published><updated>2026-11-08T15:00:00.171-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><category scheme="http://www.blogger.com/atom/ns#" term="Testing"/><title type='text'>El title de un botón disabled no aparece (shadcn/ui)</title><content type='html'>&lt;p&gt;Quieres explicar al usuario por qué un botón está deshabilitado, así que le pones un atributo &lt;code&gt;title&lt;/code&gt;. Pasas el mouse por encima y el tooltip nunca aparece:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;Button disabled title=&quot;Completa los campos obligatorios&quot;&amp;gt;
  Guardar
&amp;lt;/Button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No hay ningún error en la consola. El mismo &lt;code&gt;title&lt;/code&gt; funciona en el botón cuando está habilitado, y deja de funcionar al deshabilitarlo. Este artículo explica la causa en el componente &lt;code&gt;Button&lt;/code&gt; de shadcn/ui (React y Tailwind) y cómo mostrar el motivo con un contenedor.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La clase base del &lt;code&gt;Button&lt;/code&gt; de shadcn/ui incluye &lt;code&gt;disabled:pointer-events-none disabled:opacity-50&lt;/code&gt;. Con &lt;code&gt;pointer-events: none&lt;/code&gt;, según MDN, el elemento generalmente no es el destino de los eventos del puntero: estos lo &quot;atraviesan&quot; y se dirigen a lo que haya debajo. El tooltip nativo de &lt;code&gt;title&lt;/code&gt; depende de que el puntero esté sobre el elemento que tiene el atributo; en un botón con &lt;code&gt;pointer-events: none&lt;/code&gt;, el puntero nunca llega a él. (MDN no documenta esta relación con los tooltips; es una conclusión coherente con lo observado, no una cita.)&lt;/p&gt;
&lt;p&gt;En una prueba con Chrome se midió el efecto de la clase:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Con &lt;code&gt;disabled&lt;/code&gt; y &lt;code&gt;pointer-events: none&lt;/code&gt;, el botón no recibió ningún evento &lt;code&gt;mouseover&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Con el mismo botón dentro de un &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, ese &lt;code&gt;span&lt;/code&gt; sí recibió el &lt;code&gt;mouseover&lt;/code&gt; al pasar el puntero sobre el botón.&lt;/li&gt;
  &lt;li&gt;Con &lt;code&gt;disabled&lt;/code&gt; pero sin &lt;code&gt;pointer-events: none&lt;/code&gt;, Chrome sí envió &lt;code&gt;mouseover&lt;/code&gt; al botón deshabilitado. No se probaron otros navegadores, y el comportamiento de los controles deshabilitados puede variar entre ellos.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Además, según MDN, un botón con &lt;code&gt;disabled&lt;/code&gt; no se puede pulsar ni enfocar. Por eso un usuario de teclado nunca llega a él con la tecla Tab y no puede ver un tooltip asociado a su foco.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma que tu botón tiene la clase.&lt;/strong&gt; Como shadcn/ui copia los componentes a tu proyecto, puedes comprobarlo en PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Select-String -Path src/components/ui/button.tsx -Pattern &#39;pointer-events&#39;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Debe aparecer &lt;code&gt;disabled:pointer-events-none&lt;/code&gt; entre las clases base. Ajusta la ruta a la de tu proyecto.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pon el &lt;code&gt;title&lt;/code&gt; en un contenedor que sí reciba el puntero.&lt;/strong&gt; Un &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; alrededor del botón:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { Button } from &quot;@/components/ui/button&quot;

type Props = {
  disabled: boolean
  motivo?: string
  onGuardar: () =&amp;gt; void
}

export function BotonGuardar({ disabled, motivo, onGuardar }: Props) {
  return (
    &amp;lt;span
      className=&quot;inline-block&quot;
      tabIndex={disabled ? 0 : undefined}
      title={disabled ? motivo : undefined}
    &amp;gt;
      &amp;lt;Button disabled={disabled} onClick={onGuardar}&amp;gt;
        Guardar
      &amp;lt;/Button&amp;gt;
    &amp;lt;/span&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Decisiones del ejemplo: el &lt;code&gt;span&lt;/code&gt; se renderiza siempre, para que React no desmonte y vuelva a montar el botón cuando cambia &lt;code&gt;disabled&lt;/code&gt;; &lt;code&gt;inline-block&lt;/code&gt; hace que el contenedor ocupe lo mismo que el botón; y &lt;code&gt;tabIndex={0}&lt;/code&gt; solo está presente mientras el botón está deshabilitado, para darle un punto de foco al teclado sin añadir una parada extra cuando el botón funciona.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cubre el comportamiento con una prueba.&lt;/strong&gt; Con Vitest y Testing Library, la prueba comprueba que el &lt;code&gt;title&lt;/code&gt; está en el contenedor y no en el botón:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { describe, expect, it, vi } from &quot;vitest&quot;
import { render, screen } from &quot;@testing-library/react&quot;
import { BotonGuardar } from &quot;./BotonGuardar&quot;

const MOTIVO = &quot;Completa los campos obligatorios&quot;

describe(&quot;BotonGuardar&quot;, () =&amp;gt; {
  it(&quot;muestra el motivo en el contenedor cuando está deshabilitado&quot;, () =&amp;gt; {
    render(&amp;lt;BotonGuardar disabled motivo={MOTIVO} onGuardar={vi.fn()} /&amp;gt;)

    const contenedor = screen.getByTitle(MOTIVO)
    const boton = screen.getByRole(&quot;button&quot;, { name: &quot;Guardar&quot; })

    expect(contenedor.tagName).toBe(&quot;SPAN&quot;)
    expect(contenedor).toContainElement(boton)
    expect(boton).toBeDisabled()
    expect(boton).not.toHaveAttribute(&quot;title&quot;)
  })
})&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;&lt;code&gt;getByTitle&lt;/code&gt; busca el elemento cuyo atributo &lt;code&gt;title&lt;/code&gt; coincide con el texto. Si pones el &lt;code&gt;title&lt;/code&gt; de vuelta en el botón, esta prueba falla con &lt;code&gt;Expected: &quot;SPAN&quot; / Received: &quot;BUTTON&quot;&lt;/code&gt;. Un detalle: jsdom no carga tus clases de Tailwind, así que la prueba no detecta &lt;code&gt;pointer-events&lt;/code&gt;; verifica la estructura que lo soluciona, no el efecto visual.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si necesitas que también funcione con teclado o en pantallas táctiles, usa un Tooltip en vez de &lt;code&gt;title&lt;/code&gt;.&lt;/strong&gt; La documentación de shadcn/ui para Tooltip incluye un ejemplo para botones deshabilitados con el mismo recurso, un &lt;code&gt;span&lt;/code&gt; como hijo del disparador:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;Tooltip&amp;gt;
  &amp;lt;TooltipTrigger asChild&amp;gt;
    &amp;lt;span className=&quot;inline-block&quot; tabIndex={0}&amp;gt;
      &amp;lt;Button disabled&amp;gt;Guardar&amp;lt;/Button&amp;gt;
    &amp;lt;/span&amp;gt;
  &amp;lt;/TooltipTrigger&amp;gt;
  &amp;lt;TooltipContent&amp;gt;Completa los campos obligatorios&amp;lt;/TooltipContent&amp;gt;
&amp;lt;/Tooltip&amp;gt;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El &lt;code&gt;tabIndex={0}&lt;/code&gt; es un añadido de este artículo, no parte del ejemplo oficial. La documentación de Radix indica que el Tooltip se abre al pasar el puntero o al recibir foco el disparador, y no menciona los botones deshabilitados; por eso conviene que el &lt;code&gt;span&lt;/code&gt; pueda recibir foco.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;Ejecuta la prueba (&lt;code&gt;npx vitest run&lt;/code&gt;). Debe pasar con el contenedor y fallar si mueves el &lt;code&gt;title&lt;/code&gt; al botón.&lt;/li&gt;
  &lt;li&gt;En el navegador, pasa el mouse sobre el botón deshabilitado y espera aproximadamente un segundo: debe aparecer el tooltip con el motivo. El tooltip nativo lo dibuja el navegador fuera del DOM, así que no se puede inspeccionar con las herramientas de desarrollo ni con las pruebas automatizadas; esta comprobación es manual.&lt;/li&gt;
  &lt;li&gt;Comprueba a qué elemento llega el puntero. Ubica las coordenadas del botón en la consola y pregunta qué elemento hay allí:
&lt;pre&gt;&lt;code&gt;const b = document.querySelector(&#39;button:disabled&#39;)
const r = b.getBoundingClientRect()
document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)&lt;/code&gt;&lt;/pre&gt;
    En la prueba devolvió el &lt;code&gt;span&lt;/code&gt; contenedor, no el botón.&lt;/li&gt;
  &lt;li&gt;Con el teclado, pulsa Tab: el contenedor debe recibir el foco mientras el botón esté deshabilitado.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Los clics atraviesan el botón deshabilitado&lt;/h3&gt;
&lt;p&gt;Con &lt;code&gt;pointer-events: none&lt;/code&gt;, un clic sobre el botón deshabilitado se dirige al elemento que está debajo. En la prueba, el clic llegó al &lt;code&gt;span&lt;/code&gt; y subió hasta el manejador &lt;code&gt;onClick&lt;/code&gt; de un contenedor superior. Si el botón está dentro de una tarjeta o fila clicable, puede dispararse esa acción. Si no lo quieres, detén la propagación en el &lt;code&gt;span&lt;/code&gt; (&lt;code&gt;onClick={(e) =&amp;gt; e.stopPropagation()}&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;Usar &lt;code&gt;aria-disabled&lt;/code&gt; en lugar de &lt;code&gt;disabled&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Un botón con &lt;code&gt;aria-disabled=&quot;true&quot;&lt;/code&gt; sigue siendo enfocable y puede recibir el puntero, por lo que no necesita el contenedor. Pero MDN aclara que este atributo solo cambia la semántica que se comunica a las tecnologías de apoyo: tienes que suprimir tú mismo la acción (también la activación con teclado), darle el estilo de deshabilitado y no puedes apoyarte en las clases &lt;code&gt;disabled:&lt;/code&gt; de shadcn/ui.&lt;/p&gt;
&lt;h3&gt;Las limitaciones del atributo &lt;code&gt;title&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;MDN advierte que su uso es muy problemático para personas que usan pantallas táctiles, navegan con teclado o usan tecnologías de apoyo, y recomienda una técnica más accesible para los tooltips. Si el motivo es importante, considera además mostrarlo como texto visible junto al botón.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;El &lt;code&gt;Button&lt;/code&gt; de shadcn/ui aplica &lt;code&gt;disabled:pointer-events-none&lt;/code&gt;, y con eso el puntero nunca llega al botón deshabilitado, así que su &lt;code&gt;title&lt;/code&gt; no se muestra.&lt;/li&gt;
  &lt;li&gt;La solución es poner el &lt;code&gt;title&lt;/code&gt; (o el disparador del Tooltip) en un &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; que envuelva el botón.&lt;/li&gt;
  &lt;li&gt;Renderiza el &lt;code&gt;span&lt;/code&gt; siempre y añade &lt;code&gt;tabIndex={0}&lt;/code&gt; solo mientras el botón está deshabilitado.&lt;/li&gt;
  &lt;li&gt;Una prueba con &lt;code&gt;getByTitle&lt;/code&gt; verifica que el título está en el contenedor; el tooltip nativo se comprueba a mano.&lt;/li&gt;
  &lt;li&gt;Para teclado y pantallas táctiles, prefiere un Tooltip al atributo &lt;code&gt;title&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events&quot;&gt;MDN: &lt;code&gt;pointer-events&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://ui.shadcn.com/docs/components/radix/tooltip&quot;&gt;shadcn/ui: Tooltip (sección Disabled Button)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/title&quot;&gt;MDN: atributo global &lt;code&gt;title&lt;/code&gt; (accesibilidad)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7015796266826778517/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/el-title-de-un-boton-disabled-no.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7015796266826778517'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7015796266826778517'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/el-title-de-un-boton-disabled-no.html' title='El title de un botón disabled no aparece (shadcn/ui)'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-2720620200768331139</id><published>2026-11-08T09:00:00.000-05:00</published><updated>2026-11-08T09:00:00.124-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="CSS"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><title type='text'>Modal sin scroll en pantallas de poca altura (CSS)</title><content type='html'>&lt;p&gt;Abres un modal con una lista larga. En una pantalla alta se ve bien, pero en una ventana ancha y poco alta (una laptop con la ventana reducida o con las herramientas de desarrollo abiertas, por ejemplo) el contenido queda cortado: no aparece ninguna barra de desplazamiento, la rueda del mouse no hace nada y el botón final del formulario es inalcanzable. No hay ningún mensaje de error; el problema está en el CSS. Este es el patrón que lo provoca:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* Panel.module.css: clase compartida por varias pantallas */
.panel {
  max-height: 90vh;
  overflow: hidden; /* por ejemplo, para recortar esquinas redondeadas */
}

@media (max-width: 768px) {
  .panel {
    overflow-y: auto;
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La clase compartida solo permite el desplazamiento dentro de una media query de &lt;strong&gt;ancho&lt;/strong&gt;. Cuando la ventana mide más de 768 px de ancho, esa regla no se aplica y el contenedor queda con &lt;code&gt;overflow: hidden&lt;/code&gt;. Según MDN, con ese valor el contenido que sobresale se recorta, no hay barras de desplazamiento y el usuario no puede verlo arrastrando en una pantalla táctil ni con la rueda del mouse.&lt;/p&gt;
&lt;p&gt;El error es relacionar el ancho de la ventana con la necesidad de scroll. Lo que determina si hace falta desplazarse es la &lt;strong&gt;altura disponible frente a la altura del contenido&lt;/strong&gt;, y ese cruce puede darse con cualquier ancho. Si el modal tiene &lt;code&gt;position: fixed&lt;/code&gt;, tampoco sirve desplazar la página de fondo: el modal no se mueve con ella.&lt;/p&gt;
&lt;p&gt;En la prueba con 8 elementos en la lista y una ventana de 1280 × 400 píxeles, el contenedor tenía un contenido de 529 px dentro de un área visible de 360 px, con &lt;code&gt;overflow-y&lt;/code&gt; en &lt;code&gt;hidden&lt;/code&gt;: el botón quedó fuera de la ventana y no se pudo alcanzar con la rueda ni con un clic. Con una ventana de 600 px de ancho, la misma pantalla sí funcionaba, porque ahí se aplicaba la media query. Por eso el fallo pasa desapercibido si solo se prueba en móvil o en un monitor grande.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reproduce el problema.&lt;/strong&gt; Abre el modal con más elementos de los que caben y reduce la altura de la ventana (por ejemplo, a unos 400 px). En la consola, comprueba si hay desbordamiento y qué valor tiene &lt;code&gt;overflow-y&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const modal = document.querySelector(&#39;[role=&quot;dialog&quot;]&#39;)
console.log(getComputedStyle(modal).overflowY, modal.scrollHeight, modal.clientHeight)
// hidden 529 360  -&gt; el contenido es más alto que el área visible y está recortado&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Los números dependerán de tu contenido; lo importante es que &lt;code&gt;scrollHeight&lt;/code&gt; sea mayor que &lt;code&gt;clientHeight&lt;/code&gt; con &lt;code&gt;hidden&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Dale al modal su propio contenedor, con altura máxima y scroll independientes del ancho.&lt;/strong&gt; No reutilices la clase compartida:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.dialogo {
  box-sizing: border-box;
  max-height: 90vh; /* respaldo para navegadores sin dvh */
  max-height: 90dvh;
  overflow-y: auto;
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Con &lt;code&gt;overflow-y: auto&lt;/code&gt; la barra solo aparece cuando el contenido supera la altura máxima. &lt;code&gt;box-sizing: border-box&lt;/code&gt; hace que el relleno (&lt;em&gt;padding&lt;/em&gt;) cuente dentro de esos 90 %; sin él, la altura máxima se aplica solo al contenido y el modal puede terminar más alto que la ventana.&lt;/p&gt;
    &lt;p&gt;La unidad &lt;code&gt;dvh&lt;/code&gt; (altura de la ventana dinámica) tiene en cuenta las barras del navegador móvil que se expanden o se retraen; según web.dev, está disponible desde Chrome 108, Edge 108, Firefox 101 y Safari 15.4. La línea con &lt;code&gt;vh&lt;/code&gt; sirve de respaldo: un navegador que no entiende &lt;code&gt;dvh&lt;/code&gt; descarta esa declaración y conserva la anterior. En una prueba con una unidad inexistente (&lt;code&gt;90foo&lt;/code&gt;), el navegador mantuvo el valor en &lt;code&gt;vh&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Úsalo en el componente.&lt;/strong&gt; El modal ya no depende de la clase compartida:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div className={styles.fondo}&amp;gt;
  &amp;lt;div
    className={styles.dialogo}
    role=&quot;dialog&quot;
    aria-modal=&quot;true&quot;
    aria-labelledby=&quot;titulo-modal&quot;
  &amp;gt;
    &amp;lt;h2 id=&quot;titulo-modal&quot;&amp;gt;Elementos&amp;lt;/h2&amp;gt;
    &amp;lt;ul&amp;gt;{items.map((item) =&amp;gt; &amp;lt;li key={item.id}&amp;gt;{item.nombre}&amp;lt;/li&amp;gt;)}&amp;lt;/ul&amp;gt;
    &amp;lt;button type=&quot;button&quot; onClick={guardar}&amp;gt;Guardar&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Opcional: mantén visibles el título y los botones y desplaza solo la lista.&lt;/strong&gt; Es una variante de diseño, no un requisito:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.dialogo {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  max-height: 90dvh;
}

.cabecera,
.pie {
  flex: none;
}

.cuerpo {
  overflow-y: auto;
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Aquí el contenedor no desplaza nada; el scroll queda en &lt;code&gt;.cuerpo&lt;/code&gt;, y la cabecera y el pie siempre se ven. En la prueba funcionó sin ajustes adicionales.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;Reduce la ventana a unos 1280 × 400 con 8 o más elementos en la lista. Al mover la rueda del mouse sobre el modal, el contenido debe desplazarse y el botón final debe poder alcanzarse y pulsarse. En la prueba, el borde inferior del botón pasó de estar a 525 px del borde superior de la ventana (fuera de una ventana de 400 px de alto) a 356 px tras desplazar, y el clic se registró.&lt;/li&gt;
  &lt;li&gt;Repite la comprobación de la consola: &lt;code&gt;getComputedStyle(modal).overflowY&lt;/code&gt; debe devolver &lt;code&gt;auto&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Prueba una ventana alta (por ejemplo 1280 × 900): no debe aparecer barra de desplazamiento si todo cabe (&lt;code&gt;scrollHeight&lt;/code&gt; igual a &lt;code&gt;clientHeight&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;Prueba una ventana estrecha (600 px de ancho): debe seguir funcionando igual.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Una media query de altura para compactar el diseño&lt;/h3&gt;
&lt;p&gt;La característica &lt;code&gt;height&lt;/code&gt; de las media queries mide la altura de la ventana y admite &lt;code&gt;min-height&lt;/code&gt; y &lt;code&gt;max-height&lt;/code&gt;. Puedes usarla para reducir espacios en pantallas bajas, por ejemplo &lt;code&gt;@media (max-height: 40rem)&lt;/code&gt;, pero como complemento: el scroll del modal no debe depender de ella.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;vh&lt;/code&gt;, &lt;code&gt;dvh&lt;/code&gt; o &lt;code&gt;svh&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;MDN indica que &lt;code&gt;vh&lt;/code&gt; equivale a &lt;code&gt;lvh&lt;/code&gt; (la altura con las barras del navegador retraídas), por lo que en móviles con barras visibles el modal puede quedar parcialmente fuera de la ventana. &lt;code&gt;dvh&lt;/code&gt; se ajusta a las barras, pero MDN advierte que su tamaño no es estable y puede cambiar mientras el usuario se desplaza. &lt;code&gt;svh&lt;/code&gt; usa la ventana más pequeña y es la opción más predecible si no quieres cambios de tamaño en pleno scroll.&lt;/p&gt;
&lt;h3&gt;El mismo fallo en otros contenedores&lt;/h3&gt;
&lt;p&gt;Busca otras clases reutilizadas cuyo &lt;code&gt;overflow&lt;/code&gt; solo cambie dentro de una media query de ancho. Cualquier contenedor con altura máxima y &lt;code&gt;overflow: hidden&lt;/code&gt; puede esconder contenido de la misma forma.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Un contenedor con &lt;code&gt;overflow: hidden&lt;/code&gt; recorta el contenido sin ofrecer barra de desplazamiento ni forma de alcanzarlo.&lt;/li&gt;
  &lt;li&gt;El scroll no debe depender de una media query de ancho: ventanas anchas pero bajas siguen necesitándolo.&lt;/li&gt;
  &lt;li&gt;Dale al modal su propio contenedor con &lt;code&gt;max-height: 90dvh&lt;/code&gt; (con &lt;code&gt;90vh&lt;/code&gt; como respaldo), &lt;code&gt;overflow-y: auto&lt;/code&gt; y &lt;code&gt;box-sizing: border-box&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Si quieres cabecera y pie fijos, usa una columna flex y pon &lt;code&gt;overflow-y: auto&lt;/code&gt; solo en el cuerpo.&lt;/li&gt;
  &lt;li&gt;Prueba con poca altura, no solo con poco ancho.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/overflow&quot;&gt;MDN: &lt;code&gt;overflow&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/length&quot;&gt;MDN: &lt;code&gt;&amp;lt;length&amp;gt;&lt;/code&gt; (unidades de ventana &lt;code&gt;vh&lt;/code&gt;, &lt;code&gt;dvh&lt;/code&gt;, &lt;code&gt;svh&lt;/code&gt;, &lt;code&gt;lvh&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://web.dev/blog/viewport-units&quot;&gt;web.dev: The large, small, and dynamic viewport units&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/2720620200768331139/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/modal-sin-scroll-en-pantallas-de-poca.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/2720620200768331139'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/2720620200768331139'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/modal-sin-scroll-en-pantallas-de-poca.html' title='Modal sin scroll en pantallas de poca altura (CSS)'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-3829155348627317582</id><published>2026-11-07T15:00:00.000-05:00</published><updated>2026-11-07T15:00:00.184-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="CSS"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><title type='text'>El texto de la pestaña activa desaparece al pasar el mouse</title><content type='html'>&lt;p&gt;Tienes un componente de pestañas con CSS Modules. La pestaña activa se ve bien (texto claro sobre fondo de color), pero en cuanto pasas el mouse por encima, el texto desaparece y queda solo un bloque de color liso. No hay ningún error en la consola: el síntoma es puramente visual. Este es el patrón de CSS que lo causa:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* Tabs.module.css */
.tab {
  color: var(--color-muted);
  background: transparent;
}

.tab:hover {
  color: var(--color-primary);
}

.tabActive {
  color: var(--color-on-primary);
  background: var(--color-primary);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En JSX, la pestaña activa recibe las dos clases, por ejemplo &lt;code&gt;className={`${styles.tab} ${activa ? styles.tabActive : &#39;&#39;}`}&lt;/code&gt;. Aunque &lt;code&gt;.tabActive&lt;/code&gt; aparece después en el archivo, al pasar el mouse el color del texto cambia al color primario, el mismo del fondo.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Cuando dos reglas de la misma hoja de estilos compiten por la misma propiedad, gana la de mayor especificidad; el orden en el archivo solo desempata cuando la especificidad es igual. MDN explica que la especificidad se compara en tres columnas (identificadores, clases y tipos), y que las clases, los atributos y las pseudoclases como &lt;code&gt;:hover&lt;/code&gt; suman lo mismo en la columna central. En este caso:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;.tab:hover&lt;/code&gt; es una clase más una pseudoclase: especificidad &lt;code&gt;0-2-0&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;.tabActive&lt;/code&gt; es una sola clase: especificidad &lt;code&gt;0-1-0&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Para la propiedad &lt;code&gt;color&lt;/code&gt;, la regla de &lt;code&gt;:hover&lt;/code&gt; gana a la de la clase activa, aunque esta última esté más abajo. El &lt;code&gt;background&lt;/code&gt; no tiene conflicto (solo lo define &lt;code&gt;.tabActive&lt;/code&gt;), así que se mantiene. El resultado es texto en color primario sobre fondo en color primario. Con colores idénticos, la relación de contraste es de 1:1; el criterio 1.4.3 de WCAG pide al menos 4.5:1 para texto normal. En una prueba con colores de ejemplo, el contraste calculado de la pestaña activa con el mouse encima fue de 1,00:1.&lt;/p&gt;
&lt;p&gt;CSS Modules no cambia esto. Solo renombra las clases a nombres únicos, y la especificidad de los selectores sigue siendo la misma.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma la causa.&lt;/strong&gt; En las herramientas de desarrollo del navegador, selecciona la pestaña activa y fuerza el estado &lt;code&gt;:hover&lt;/code&gt; desde el panel de estilos. Verás tachada la declaración de &lt;code&gt;color&lt;/code&gt; de &lt;code&gt;.tabActive&lt;/code&gt; y aplicada la de &lt;code&gt;.tab:hover&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Excluye la pestaña activa de la regla de hover.&lt;/strong&gt; Es la opción más clara y no depende del orden de las reglas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.tab:not(.tabActive):hover {
  color: var(--color-primary);
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La regla de hover deja de coincidir con la pestaña activa, así que nada pisa su color. Procesado con &lt;code&gt;postcss-modules&lt;/code&gt;, la clase local dentro de &lt;code&gt;:not()&lt;/code&gt; también se renombra y el selector sigue funcionando. Según MDN, &lt;code&gt;:not()&lt;/code&gt; toma la especificidad del selector más específico de su argumento; aquí eso solo importa para las demás pestañas.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Alternativa: subir la especificidad de la regla activa.&lt;/strong&gt; Si prefieres no tocar la regla de hover, combina las dos clases:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.tab:hover {
  color: var(--color-primary);
}

.tab.tabActive {
  color: var(--color-on-primary);
  background: var(--color-primary);
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Atención: &lt;code&gt;.tab.tabActive&lt;/code&gt; vale &lt;code&gt;0-2-0&lt;/code&gt;, igual que &lt;code&gt;.tab:hover&lt;/code&gt;. Con especificidad igual decide el orden, y en la prueba el problema volvió al declarar &lt;code&gt;.tab.tabActive&lt;/code&gt; antes que &lt;code&gt;.tab:hover&lt;/code&gt;. Si usas esta opción, mantén la regla activa después de la de hover, o añade un selector explícito que no dependa del orden:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.tab.tabActive:hover {
  color: var(--color-on-primary);
}&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Busca el mismo patrón en el resto del proyecto.&lt;/strong&gt; En PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-ChildItem src -Recurse -Filter *.module.css | Select-String -Pattern &#39;:hover|:focus|:active&#39;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Revisa cada coincidencia: si el selector con pseudoclase apunta a una clase base y existe otra clase &quot;de estado&quot; (activa, seleccionada, primaria) que define la misma propiedad, tienes el mismo conflicto.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;La comprobación manual consiste en pasar el mouse sobre la pestaña activa: el texto debe seguir visible. Para una comprobación más objetiva, fuerza &lt;code&gt;:hover&lt;/code&gt; en las herramientas de desarrollo y compara los valores de &lt;code&gt;color&lt;/code&gt; y &lt;code&gt;background-color&lt;/code&gt; en la pestaña &lt;em&gt;Computed&lt;/em&gt;; no deben ser iguales. El selector de color de Chrome también muestra la relación de contraste.&lt;/p&gt;
&lt;p&gt;Si usas Playwright, puedes automatizarlo con propiedades estándar del DOM:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const activa = page.locator(&#39;[role=&quot;tab&quot;][aria-selected=&quot;true&quot;]&#39;)
await activa.hover()
const [color, fondo] = await activa.evaluate((el) =&gt; {
  const estilos = getComputedStyle(el)
  return [estilos.color, estilos.backgroundColor]
})
// color y fondo deben ser distintos&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Comprueba también que las pestañas inactivas conservan su efecto de hover: con la opción de &lt;code&gt;:not()&lt;/code&gt;, pasar el mouse por una pestaña inactiva debe seguir cambiando su color al primario.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;El mismo conflicto con &lt;code&gt;:focus&lt;/code&gt;, &lt;code&gt;:active&lt;/code&gt; o &lt;code&gt;:disabled&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Cada pseudoclase simple agregada a la clase base (&lt;code&gt;:hover&lt;/code&gt;, &lt;code&gt;:focus-visible&lt;/code&gt;, &lt;code&gt;:active&lt;/code&gt;) suma el equivalente a una clase de especificidad. Por eso un &lt;code&gt;.boton:focus-visible&lt;/code&gt; o un &lt;code&gt;.boton:active&lt;/code&gt; también puede imponerse a una clase de estado como &lt;code&gt;.botonPrimario&lt;/code&gt;. La corrección es la misma: excluir el estado con &lt;code&gt;:not()&lt;/code&gt; o declarar el selector combinado explícito.&lt;/p&gt;
&lt;h3&gt;Si el hover cambia también el fondo&lt;/h3&gt;
&lt;p&gt;Si la regla de hover define &lt;code&gt;color&lt;/code&gt; y &lt;code&gt;background&lt;/code&gt;, ambas propiedades de la pestaña activa serían reemplazadas por las del hover (la regla de hover gana en las dos). Según los colores elegidos, el texto podría seguir siendo legible, pero la pestaña activa perdería su fondo distintivo al pasar el mouse. La exclusión con &lt;code&gt;:not()&lt;/code&gt; resuelve los dos casos.&lt;/p&gt;
&lt;h3&gt;No uses &lt;code&gt;!important&lt;/code&gt; como atajo&lt;/h3&gt;
&lt;p&gt;Funciona a corto plazo, pero obliga a usar más &lt;code&gt;!important&lt;/code&gt; en cada excepción posterior. Es preferible ajustar los selectores.&lt;/p&gt;
&lt;h3&gt;No dependas del orden entre archivos&lt;/h3&gt;
&lt;p&gt;Cuando la solución depende de que una regla se declare después de otra y ambas están en archivos distintos, el resultado puede cambiar según el orden en que el empaquetador incluya los módulos CSS. Con especificidades distintas, ese riesgo desaparece.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Una clase con pseudoclase (&lt;code&gt;.tab:hover&lt;/code&gt;, &lt;code&gt;0-2-0&lt;/code&gt;) gana a una clase sola (&lt;code&gt;.tabActive&lt;/code&gt;, &lt;code&gt;0-1-0&lt;/code&gt;), sin importar el orden en el archivo.&lt;/li&gt;
  &lt;li&gt;El resultado fue texto y fondo del mismo color: contraste 1:1, muy por debajo del 4.5:1 que pide WCAG.&lt;/li&gt;
  &lt;li&gt;Solución preferida: &lt;code&gt;.tab:not(.tabActive):hover&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Si subes la especificidad con &lt;code&gt;.tab.tabActive&lt;/code&gt;, queda un empate con &lt;code&gt;.tab:hover&lt;/code&gt; y decide el orden: declárala después.&lt;/li&gt;
  &lt;li&gt;Busca el mismo patrón en otras pseudoclases y componentes con clases de estado.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Specificity&quot;&gt;MDN: Specificity&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/:not&quot;&gt;MDN: &lt;code&gt;:not()&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html&quot;&gt;W3C: Understanding SC 1.4.3 Contrast (Minimum)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/3829155348627317582/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/el-texto-de-la-pestana-activa.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/3829155348627317582'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/3829155348627317582'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/el-texto-de-la-pestana-activa.html' title='El texto de la pestaña activa desaparece al pasar el mouse'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-6298971012563240291</id><published>2026-11-07T09:00:00.000-05:00</published><updated>2026-11-07T09:00:00.180-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="JavaScript"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><title type='text'>ESLint: &quot;is defined but never used&quot; en componentes JSX</title><content type='html'>&lt;p&gt;Ejecutas ESLint en un proyecto React con Vite y configuración flat (ESLint 9), y varios componentes aparecen como no usados aunque se renderizan sin problema:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx eslint src

src/App.jsx
  1:8   error  &#39;MiComponente&#39; is defined but never used  no-unused-vars
  2:10  error  &#39;Otro&#39; is defined but never used          no-unused-vars&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En ese archivo, &lt;code&gt;MiComponente&lt;/code&gt; se usa como &lt;code&gt;&amp;lt;MiComponente /&amp;gt;&lt;/code&gt; y &lt;code&gt;Otro&lt;/code&gt; como &lt;code&gt;&amp;lt;Otro.Hijo /&amp;gt;&lt;/code&gt;. La aplicación funciona; el error es un falso positivo de la regla &lt;code&gt;no-unused-vars&lt;/code&gt;. Este artículo explica por qué ocurre y cómo corregirlo activando una sola regla de &lt;code&gt;eslint-plugin-react&lt;/code&gt;, sin llenar el proyecto de errores nuevos.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La regla &lt;code&gt;no-unused-vars&lt;/code&gt; del núcleo de ESLint no considera que una etiqueta JSX sea un uso de la variable. La documentación de &lt;code&gt;react/jsx-uses-vars&lt;/code&gt; lo explica así: el &lt;code&gt;no-unused-vars&lt;/code&gt; del núcleo no detecta las variables usadas en JSX, y esa regla del plugin existe para marcarlas como usadas. En la prueba de este artículo se comprobó el mismo efecto: con solo &lt;code&gt;js.configs.recommended&lt;/code&gt; y el analizador por defecto, un componente importado y usado únicamente como etiqueta se reporta como no usado.&lt;/p&gt;
&lt;p&gt;Esto solo afecta a los nombres que aparecen exclusivamente en JSX. Si el mismo identificador también se usa en código JavaScript normal (por ejemplo, se pasa a una función), la regla lo cuenta como usado y no hay error.&lt;/p&gt;
&lt;p&gt;Por qué no lo viste antes: la plantilla de React de &lt;code&gt;create-vite&lt;/code&gt; (versión 7.0.0, la que se revisó) incluye en su &lt;code&gt;eslint.config.js&lt;/code&gt; esta excepción, que oculta el problema para cualquier nombre que empiece con mayúscula o guion bajo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&#39;no-unused-vars&#39;: [&#39;error&#39;, { varsIgnorePattern: &#39;^[A-Z_]&#39; }],&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Si tu configuración no tiene esa línea (por ejemplo, porque la quitaste, o porque armaste el archivo solo con &lt;code&gt;js.configs.recommended&lt;/code&gt; y los plugins de hooks y refresh), el falso positivo aparece.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Instala el plugin de React para ESLint.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install --save-dev eslint-plugin-react&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Regístralo y activa solo &lt;code&gt;react/jsx-uses-vars&lt;/code&gt;.&lt;/strong&gt; Un &lt;code&gt;eslint.config.js&lt;/code&gt; mínimo queda así; si ya tienes un bloque para tus archivos &lt;code&gt;.jsx&lt;/code&gt; (con react-hooks o react-refresh), agrega ahí &lt;code&gt;react&lt;/code&gt; en &lt;code&gt;plugins&lt;/code&gt; y la regla en &lt;code&gt;rules&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import js from &#39;@eslint/js&#39;
import globals from &#39;globals&#39;
import react from &#39;eslint-plugin-react&#39;

export default [
  { ignores: [&#39;dist&#39;] },
  js.configs.recommended,
  {
    files: [&#39;**/*.{js,jsx}&#39;],
    plugins: { react },
    languageOptions: {
      ecmaVersion: &#39;latest&#39;,
      globals: globals.browser,
      parserOptions: {
        ecmaFeatures: { jsx: true },
        sourceType: &#39;module&#39;,
      },
    },
    rules: {
      &#39;react/jsx-uses-vars&#39;: &#39;error&#39;,
    },
  },
]&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El nombre del plugin en &lt;code&gt;plugins&lt;/code&gt; (&lt;code&gt;react&lt;/code&gt;) define el prefijo de las reglas (&lt;code&gt;react/...&lt;/code&gt;). Según la documentación de la regla, &lt;code&gt;react/jsx-uses-vars&lt;/code&gt; solo tiene efecto si &lt;code&gt;no-unused-vars&lt;/code&gt; está activada, algo que ya hace &lt;code&gt;js.configs.recommended&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Opcional: detecta imports que faltan.&lt;/strong&gt; Como ESLint no ve las etiquetas JSX como referencias, tampoco avisa cuando usas un componente que nunca importaste (&lt;code&gt;no-undef&lt;/code&gt; guarda silencio y el error aparece recién al ejecutar la app). Esta regla del mismo plugin lo cubre:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rules: {
  &#39;react/jsx-uses-vars&#39;: &#39;error&#39;,
  &#39;react/jsx-no-undef&#39;: &#39;error&#39;,
},&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Evita activar el preset completo solo para esto.&lt;/strong&gt; &lt;code&gt;react.configs.flat.recommended&lt;/code&gt; incluye muchas más reglas. En la prueba, un componente tan simple como &lt;code&gt;function Tarjeta({ titulo, contenido })&lt;/code&gt; sin PropTypes generó errores &lt;code&gt;react/prop-types&lt;/code&gt; por cada propiedad, además de un aviso de que no se había definido la versión de React. En un proyecto real con muchos componentes, eso puede sumar cientos de errores que no tienen relación con el falso positivo. Si más adelante quieres el preset, agrega también &lt;code&gt;react.configs.flat[&#39;jsx-runtime&#39;]&lt;/code&gt; (la documentación del plugin lo indica para React 17 o superior), define &lt;code&gt;settings.react.version&lt;/code&gt; y decide si desactivas &lt;code&gt;&#39;react/prop-types&#39;: &#39;off&#39;&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npx eslint src&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Los errores de componentes usados solo en JSX deben desaparecer. Lo que quede son problemas reales: en la prueba, el único error restante fue un componente sin importar, reportado por &lt;code&gt;react/jsx-no-undef&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Para confirmar que la regla está activa en un archivo concreto, en PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx eslint --print-config src/App.jsx | Select-String &#39;jsx-uses-vars&#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Para comprobar que no apagaste la detección general, deja un import sin usar en cualquier archivo, por ejemplo &lt;code&gt;import { sinUsar } from &#39;./util.js&#39;&lt;/code&gt;. ESLint debe seguir reportando &lt;code&gt;&#39;sinUsar&#39; is defined but never used&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;La alternativa de la plantilla: &lt;code&gt;varsIgnorePattern&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Puedes volver al enfoque de la plantilla de Vite con &lt;code&gt;&#39;no-unused-vars&#39;: [&#39;error&#39;, { varsIgnorePattern: &#39;^[A-Z_]&#39; }]&lt;/code&gt;. La opción &lt;code&gt;varsIgnorePattern&lt;/code&gt; exime de la comprobación a las variables cuyo nombre coincide con la expresión regular. Funciona, pero a costa de que ESLint deje de avisar de cualquier variable, función o importación con nombre en mayúscula que realmente no se use. Con &lt;code&gt;react/jsx-uses-vars&lt;/code&gt; mantienes la detección completa.&lt;/p&gt;
&lt;h3&gt;Proyectos con TypeScript&lt;/h3&gt;
&lt;p&gt;Con &lt;code&gt;typescript-eslint&lt;/code&gt; el problema no apareció en la prueba: un componente importado y usado solo como etiqueta en un archivo &lt;code&gt;.tsx&lt;/code&gt; no se reportó, mientras que un import realmente sin usar sí. Si ves este error en un proyecto TypeScript, revisa que el analizador de &lt;code&gt;typescript-eslint&lt;/code&gt; esté aplicado a esos archivos.&lt;/p&gt;
&lt;h3&gt;¿Y &lt;code&gt;react/jsx-uses-react&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;Sirve para el caso en que importas &lt;code&gt;React&lt;/code&gt; solo para que la transformación JSX clásica lo use. Con la transformación automática de JSX (React 17 o superior), no necesitas importar &lt;code&gt;React&lt;/code&gt; en cada archivo: los archivos de la plantilla de React de &lt;code&gt;create-vite&lt;/code&gt; que se revisó (&lt;code&gt;main.jsx&lt;/code&gt; y &lt;code&gt;App.jsx&lt;/code&gt;) no lo importan. El README del plugin indica que la configuración &lt;code&gt;jsx-runtime&lt;/code&gt; desactiva esta regla.&lt;/p&gt;
&lt;h3&gt;La regla no tiene efecto&lt;/h3&gt;
&lt;p&gt;Revisa tres cosas: que el plugin esté registrado en &lt;code&gt;plugins&lt;/code&gt; con el mismo nombre que usas en el prefijo de la regla, que el bloque tenga un &lt;code&gt;files&lt;/code&gt; que incluya tus archivos &lt;code&gt;.jsx&lt;/code&gt;, y que &lt;code&gt;no-unused-vars&lt;/code&gt; esté realmente activada para esos archivos.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;El &lt;code&gt;no-unused-vars&lt;/code&gt; del núcleo de ESLint no cuenta las etiquetas JSX como uso, así que marca componentes que sí renderizas.&lt;/li&gt;
  &lt;li&gt;La plantilla de Vite lo oculta con &lt;code&gt;varsIgnorePattern: &#39;^[A-Z_]&#39;&lt;/code&gt;; sin esa línea, el falso positivo aparece.&lt;/li&gt;
  &lt;li&gt;Instala &lt;code&gt;eslint-plugin-react&lt;/code&gt; y activa solo &lt;code&gt;&#39;react/jsx-uses-vars&#39;: &#39;error&#39;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;No actives el preset completo solo para esto: añade errores como &lt;code&gt;react/prop-types&lt;/code&gt; que no tienen relación.&lt;/li&gt;
  &lt;li&gt;Añade &lt;code&gt;react/jsx-no-undef&lt;/code&gt; si quieres que ESLint detecte componentes sin importar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://eslint.org/docs/latest/rules/no-unused-vars&quot;&gt;ESLint: regla &lt;code&gt;no-unused-vars&lt;/code&gt; (opción &lt;code&gt;varsIgnorePattern&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-uses-vars.md&quot;&gt;eslint-plugin-react: regla &lt;code&gt;jsx-uses-vars&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/jsx-eslint/eslint-plugin-react#readme&quot;&gt;eslint-plugin-react: README (uso con flat config y configuraciones &lt;code&gt;recommended&lt;/code&gt; y &lt;code&gt;jsx-runtime&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/6298971012563240291/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/eslint-is-defined-but-never-used-en.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6298971012563240291'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6298971012563240291'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/eslint-is-defined-but-never-used-en.html' title='ESLint: &quot;is defined but never used&quot; en componentes JSX'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-1617840829528825158</id><published>2026-11-06T15:00:00.000-05:00</published><updated>2026-11-06T15:00:00.115-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Windows"/><title type='text'>ssh: Could not resolve hostname con un alias de config</title><content type='html'>&lt;p&gt;Escribes el alias que definiste en &lt;code&gt;~/.ssh/config&lt;/code&gt; para abrir un túnel hacia una base de datos y ssh responde que no lo conoce:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS&amp;gt; ssh -N -L 5436:localhost:5432 mi_alias_db
ssh: Could not resolve hostname mi_alias_db: Host desconocido.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El texto después de los dos puntos depende del sistema y del idioma (en Windows con idioma inglés suele ser &lt;code&gt;No such host is known.&lt;/code&gt;; en Linux, &lt;code&gt;Name or service not known&lt;/code&gt;). El escenario es OpenSSH en Windows 11, un archivo &lt;code&gt;config&lt;/code&gt; con un servidor intermedio (bastión) mediante &lt;code&gt;ProxyJump&lt;/code&gt; y una base de datos PostgreSQL detrás de él, a la que luego te conectas desde DataGrip. Las comprobaciones de este artículo se hicieron con OpenSSH for Windows 9.5 y con OpenSSH 10.3, el que incluye Git para Windows.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;ssh busca en el archivo de configuración un bloque &lt;code&gt;Host&lt;/code&gt; que coincida con el nombre que escribiste. Si ninguno coincide, no falla por eso: trata el nombre como un servidor real, porque la documentación de &lt;code&gt;HostName&lt;/code&gt; dice que su valor por defecto es el nombre dado en la línea de comandos. Entonces intenta resolver &lt;code&gt;mi_alias_db&lt;/code&gt; por DNS y falla. Por eso este error significa casi siempre que &lt;strong&gt;el alias no existe&lt;/strong&gt; en el archivo que ssh está leyendo.&lt;/p&gt;
&lt;p&gt;En este caso, el bloque nuevo del servidor de base de datos se copió de uno existente y conservó el mismo nombre en &lt;code&gt;Host&lt;/code&gt;, así que el alias &lt;code&gt;mi_alias_db&lt;/code&gt; nunca llegó a definirse:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host bastion
    HostName bastion.example.com
    User deploy

Host mi_alias_app
    HostName app.example.com
    User deploy
    ProxyJump bastion

# Bloque nuevo copiado del anterior: faltó cambiar el nombre
Host mi_alias_app
    HostName db.example.com
    User postgres
    ProxyJump bastion
    ServerAliveInterval 30&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Además, el bloque duplicado no es &quot;config muerta&quot; del todo. El manual de &lt;code&gt;ssh_config&lt;/code&gt; establece que, salvo indicación contraria, para cada directiva se usa el primer valor especificado. Los dos bloques con el mismo &lt;code&gt;Host&lt;/code&gt; se combinan: lo que el primero ya definió (&lt;code&gt;HostName&lt;/code&gt;, &lt;code&gt;User&lt;/code&gt;) gana, y el segundo solo aporta lo que al primero le falta (aquí, &lt;code&gt;ServerAliveInterval&lt;/code&gt;). Es una mezcla fácil de pasar por alto.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Mira cómo resuelve ssh el alias, sin conectarse.&lt;/strong&gt; La opción &lt;code&gt;-G&lt;/code&gt; imprime la configuración resultante tras evaluar los bloques &lt;code&gt;Host&lt;/code&gt; y &lt;code&gt;Match&lt;/code&gt;, y termina:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:: cmd
ssh -G mi_alias_db | findstr /b /i &quot;hostname proxyjump&quot;

# PowerShell
ssh -G mi_alias_db | Select-String &#39;^(hostname|proxyjump|user) &#39;

# Git Bash o Linux
ssh -G mi_alias_db | grep -E &#39;^(hostname|proxyjump|user) &#39;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Usa &lt;code&gt;/b&lt;/code&gt; en &lt;code&gt;findstr&lt;/code&gt;: sin él, la búsqueda de &lt;code&gt;hostname&lt;/code&gt; también devuelve líneas como &lt;code&gt;canonicalizehostname false&lt;/code&gt;. Ojo con una trampa: con un alias inexistente, &lt;code&gt;ssh -G&lt;/code&gt; no da error (termina con código 0). Si la línea &lt;code&gt;hostname&lt;/code&gt; repite el alias en lugar de un servidor real, es que ningún bloque coincidió.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Localiza los bloques duplicados.&lt;/strong&gt; El modo detallado indica qué archivo se lee y qué líneas se aplican:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -v -G mi_alias_app 2&amp;gt;&amp;amp;1 | Select-String &#39;Reading configuration|Applying options&#39;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Debes ver una línea &quot;Applying options for&quot; por cada bloque que coincide. Con el archivo de arriba aparece dos veces (líneas 5 y 11). Con un alias inexistente no aparece ninguna. También puedes listar todas las líneas &lt;code&gt;Host&lt;/code&gt; con su número:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Select-String -Path &quot;$env:USERPROFILE\.ssh\config&quot; -Pattern &#39;^\s*Host\s&#39;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Haz una copia y renombra el bloque.&lt;/strong&gt; Guarda el archivo antes de tocarlo y cambia el segundo &lt;code&gt;Host&lt;/code&gt; por el alias que querías:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Copy-Item &quot;$env:USERPROFILE\.ssh\config&quot; &quot;$env:USERPROFILE\.ssh\config.bak&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Host mi_alias_db
    HostName db.example.com
    User postgres
    ProxyJump bastion
    ServerAliveInterval 30&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si usas un bloque general &lt;code&gt;Host *&lt;/code&gt;, déjalo al final: el manual recomienda poner las declaraciones más específicas al principio del archivo y los valores generales al final.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Vuelve a comprobar con &lt;code&gt;-G&lt;/code&gt;.&lt;/strong&gt; Ahora &lt;code&gt;hostname&lt;/code&gt; debe ser &lt;code&gt;db.example.com&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt; debe ser &lt;code&gt;postgres&lt;/code&gt; y &lt;code&gt;proxyjump&lt;/code&gt; debe ser &lt;code&gt;bastion&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Abre el túnel en su propia consola.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -N -L 5436:localhost:5432 mi_alias_db&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;&lt;code&gt;-N&lt;/code&gt; evita ejecutar un comando remoto (sirve solo para reenviar puertos). &lt;code&gt;-L 5436:localhost:5432&lt;/code&gt; reenvía el puerto local 5436 al puerto 5432 de &lt;code&gt;localhost&lt;/code&gt;, un nombre que se resuelve desde el servidor al que ssh se conecta, es decir, el de la base de datos. La consola se queda sin mensajes y sin devolver el control: es lo normal; no la cierres mientras uses el túnel. Si el puerto 5436 ya está ocupado, elige otro y úsalo también en el cliente.&lt;/p&gt;
    &lt;p&gt;Si el túnel se cae tras un rato inactivo, añade &lt;code&gt;-o ServerAliveInterval=30&lt;/code&gt; al comando o la línea &lt;code&gt;ServerAliveInterval 30&lt;/code&gt; al bloque. El manual explica que, si no llegan datos del servidor en ese intervalo, ssh envía un mensaje para pedir respuesta; el valor por defecto es 0, que no envía nada.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Conecta DataGrip al extremo local del túnel.&lt;/strong&gt; Crea un origen de datos de tipo PostgreSQL con Host &lt;code&gt;localhost&lt;/code&gt;, Port &lt;code&gt;5436&lt;/code&gt; y tu usuario, contraseña y base de datos. Deja desactivada la opción &quot;Use SSH tunnel&quot; de la pestaña SSH/SSL. La documentación de DataGrip describe los tipos de autenticación del túnel integrado (contraseña, par de claves y &quot;OpenSSH config and authentication agent&quot;), pero no menciona &lt;code&gt;ProxyJump&lt;/code&gt;. El soporte de saltos intermedios en el cliente SSH integrado de cada herramienta puede variar, y abrir el túnel con el ssh del sistema evita depender de eso.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;ssh -G mi_alias_db&lt;/code&gt; muestra el &lt;code&gt;hostname&lt;/code&gt;, el &lt;code&gt;user&lt;/code&gt; y el &lt;code&gt;proxyjump&lt;/code&gt; esperados, y &lt;code&gt;ssh -v -G mi_alias_db&lt;/code&gt; muestra una sola línea &quot;Applying options for&quot;.&lt;/li&gt;
  &lt;li&gt;Con el túnel abierto, en otra consola: &lt;code&gt;Get-NetTCPConnection -LocalPort 5436 -State Listen&lt;/code&gt; debe devolver una fila.&lt;/li&gt;
  &lt;li&gt;En DataGrip, &quot;Test Connection&quot; termina bien. Si tienes &lt;code&gt;psql&lt;/code&gt;, puedes probar también desde la terminal:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;psql &quot;host=localhost port=5436 dbname=mi_base user=mi_usuario&quot; -c &quot;select 1&quot;&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;El error nombra un servidor real y no el alias&lt;/h3&gt;
&lt;p&gt;Si el mensaje dice &lt;code&gt;Could not resolve hostname db.example.com&lt;/code&gt; (el valor de tu &lt;code&gt;HostName&lt;/code&gt;), el alias sí existe y el problema es otro: un &lt;code&gt;HostName&lt;/code&gt; mal escrito, un DNS interno que no resuelve o un bastión inaccesible. Distinguir entre &quot;nombre del alias&quot; y &quot;nombre del servidor&quot; en el mensaje te ahorra buscar en el lugar equivocado.&lt;/p&gt;
&lt;h3&gt;ssh no lee el archivo que crees&lt;/h3&gt;
&lt;p&gt;OpenSSH lee &lt;code&gt;config&lt;/code&gt;, sin extensión, dentro de la carpeta &lt;code&gt;.ssh&lt;/code&gt; de tu usuario. En Windows es fácil que un editor lo guarde como &lt;code&gt;config.txt&lt;/code&gt;. La línea &quot;Reading configuration data&quot; de &lt;code&gt;ssh -v -G&lt;/code&gt; muestra exactamente qué archivo se leyó. La opción &lt;code&gt;-F&lt;/code&gt; permite probar otro archivo de configuración sin tocar el de uso diario.&lt;/p&gt;
&lt;h3&gt;Un &lt;code&gt;Host *&lt;/code&gt; al principio del archivo&lt;/h3&gt;
&lt;p&gt;Como se usa el primer valor de cada directiva, un bloque general colocado arriba pisa los valores específicos de los bloques siguientes. Ponlo siempre al final.&lt;/p&gt;
&lt;h3&gt;Probar el salto sin editar el archivo&lt;/h3&gt;
&lt;p&gt;La opción &lt;code&gt;-J&lt;/code&gt; de ssh permite indicar el servidor intermedio en la línea de comandos. Es útil para descartar si el fallo está en el archivo &lt;code&gt;config&lt;/code&gt; o en el acceso al bastión (consulta el manual de ssh para la sintaxis).&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&quot;Could not resolve hostname&quot; con un alias significa que ningún bloque &lt;code&gt;Host&lt;/code&gt; coincidió y ssh trató el alias como un nombre DNS.&lt;/li&gt;
  &lt;li&gt;Un &lt;code&gt;Host&lt;/code&gt; repetido por copiar y pegar un bloque deja sin definir el alias nuevo; además, ssh usa el primer valor de cada directiva y combina los bloques duplicados.&lt;/li&gt;
  &lt;li&gt;Diagnostica con &lt;code&gt;ssh -G alias&lt;/code&gt; (el &lt;code&gt;hostname&lt;/code&gt; no debe repetir el alias) y &lt;code&gt;ssh -v -G alias&lt;/code&gt; para ver qué bloques se aplican.&lt;/li&gt;
  &lt;li&gt;Renombra el bloque correcto, abre el túnel con &lt;code&gt;ssh -N -L 5436:localhost:5432 alias&lt;/code&gt; en su propia consola y añade &lt;code&gt;ServerAliveInterval 30&lt;/code&gt; contra cortes por inactividad.&lt;/li&gt;
  &lt;li&gt;En DataGrip, usa una conexión PostgreSQL normal a &lt;code&gt;localhost:5436&lt;/code&gt; en lugar de su pestaña SSH si el cliente integrado no respeta &lt;code&gt;ProxyJump&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://man.openbsd.org/ssh_config&quot;&gt;OpenBSD: manual de &lt;code&gt;ssh_config&lt;/code&gt; (&lt;code&gt;Host&lt;/code&gt;, &lt;code&gt;HostName&lt;/code&gt;, &lt;code&gt;ProxyJump&lt;/code&gt;, &lt;code&gt;ServerAliveInterval&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://man.openbsd.org/ssh&quot;&gt;OpenBSD: manual de &lt;code&gt;ssh&lt;/code&gt; (opciones &lt;code&gt;-G&lt;/code&gt;, &lt;code&gt;-N&lt;/code&gt;, &lt;code&gt;-L&lt;/code&gt;, &lt;code&gt;-F&lt;/code&gt; y &lt;code&gt;-J&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.jetbrains.com/help/datagrip/configuring-ssh-and-ssl.html&quot;&gt;JetBrains: DataGrip, configurar SSH y SSL (túnel SSH)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/1617840829528825158/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/ssh-could-not-resolve-hostname-con-un.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1617840829528825158'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1617840829528825158'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/ssh-could-not-resolve-hostname-con-un.html' title='ssh: Could not resolve hostname con un alias de config'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7913087436983309358</id><published>2026-11-06T09:00:00.000-05:00</published><updated>2026-11-06T09:00:00.202-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Git"/><category scheme="http://www.blogger.com/atom/ns#" term="Linux"/><category scheme="http://www.blogger.com/atom/ns#" term="Windows"/><title type='text'>Git: Permission denied por falta del bit de ejecución</title><content type='html'>&lt;p&gt;Subes unos scripts desde Windows, el servidor Ubuntu los descarga con &lt;code&gt;git pull&lt;/code&gt; y ninguno se puede ejecutar:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ls -l tools/
-rw-r--r-- 1 deploy deploy  412 oct 11 10:00 build.py
$ ./tools/build.py
bash: ./tools/build.py: Permission denied&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Si los lanza systemd u otro agente, el servicio falla al arrancar el proceso (en el estado de systemd suele verse &lt;code&gt;status=203/EXEC&lt;/code&gt;). Lo habitual es arreglarlo en el servidor con &lt;code&gt;chmod +x&lt;/code&gt;, y entonces aparece el segundo problema: en el siguiente despliegue, &lt;code&gt;git pull&lt;/code&gt; se aborta.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error: Your local changes to the following files would be overwritten by merge:
	tools/build.py
Please commit your changes or stash them before you merge.
Aborting&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica por qué los archivos llegan sin permiso de ejecución, cómo corregirlo en el repositorio (para que no dependa de nadie en el servidor) y cómo salir del bloqueo del &lt;code&gt;git pull&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Git solo guarda un bit de permisos.&lt;/strong&gt; La documentación de &lt;code&gt;git fast-import&lt;/code&gt; lista los únicos modos que admite: &lt;code&gt;100644&lt;/code&gt; (archivo normal, no ejecutable), &lt;code&gt;100755&lt;/code&gt; (archivo ejecutable), &lt;code&gt;120000&lt;/code&gt; (enlace simbólico) y &lt;code&gt;160000&lt;/code&gt; (gitlink). Para un script, la diferencia entre funcionar o no en Linux es el modo &lt;code&gt;100644&lt;/code&gt; frente a &lt;code&gt;100755&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;En Windows el bit no se puede leer del sistema de archivos.&lt;/strong&gt; Al ejecutar &lt;code&gt;git init&lt;/code&gt; o &lt;code&gt;git clone&lt;/code&gt;, Git comprueba si el sistema de archivos maneja bien el bit ejecutable y fija &lt;code&gt;core.fileMode&lt;/code&gt; en consecuencia (esto lo describe la documentación de &lt;code&gt;git config&lt;/code&gt;). En un repositorio nuevo creado en Windows se observa &lt;code&gt;core.fileMode = false&lt;/code&gt;. Con ese valor, Git ignora el bit ejecutable y &lt;code&gt;git add&lt;/code&gt; registra los archivos nuevos como &lt;code&gt;100644&lt;/code&gt;, incluso si empiezan con una línea &lt;code&gt;#!&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;En Linux el valor por defecto es &lt;code&gt;true&lt;/code&gt;.&lt;/strong&gt; El &lt;code&gt;git pull&lt;/code&gt; del servidor crea los scripts con el modo registrado (&lt;code&gt;100644&lt;/code&gt;), es decir, sin permiso de ejecución.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Por eso se aborta el &lt;code&gt;git pull&lt;/code&gt; después de un &lt;code&gt;chmod&lt;/code&gt; manual.&lt;/strong&gt; Con &lt;code&gt;core.fileMode = true&lt;/code&gt;, un cambio de modo cuenta como modificación local. Si el siguiente commit toca esos mismos archivos (por ejemplo, el commit que corrige el modo), Git se niega a sobrescribirlos.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma el modo registrado.&lt;/strong&gt; Desde Windows o desde cualquier equipo con el repositorio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git ls-files -s tools/
git ls-tree HEAD tools/&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La primera columna es el modo. Si ves &lt;code&gt;100644&lt;/code&gt; en los scripts, esa es la causa.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Marca los scripts como ejecutables en el índice.&lt;/strong&gt; En Git Bash, Linux o macOS, el shell expande el comodín:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git update-index --chmod=+x tools/*.py&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En PowerShell el comodín no se expande, y &lt;code&gt;git update-index&lt;/code&gt; lo trata como un nombre literal y falla con &lt;code&gt;fatal: git update-index: cannot chmod +x &#39;tools/*.py&#39;&lt;/code&gt;. Deja que Git resuelva el patrón:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git ls-files &quot;tools/*.py&quot; | ForEach-Object { git update-index --chmod=+x $_ }&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Para un archivo que todavía no está en el repositorio, usa &lt;code&gt;git add --chmod=+x tools/nuevo.py&lt;/code&gt;. Según la documentación, la opción solo cambia el bit en el índice y deja intactos los archivos en disco; por eso funciona aunque estés en Windows.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Revisa el cambio, haz commit y sube.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git diff --cached --summary
git commit -m &quot;fix: marcar scripts como ejecutables&quot;
git push&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El resumen debe listar una línea &lt;code&gt;mode change 100644 =&amp;gt; 100755&lt;/code&gt; por cada script y ninguna modificación de contenido.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Despliega y comprueba en el servidor.&lt;/strong&gt; Tras el &lt;code&gt;git pull&lt;/code&gt;, &lt;code&gt;ls -l tools/&lt;/code&gt; debe mostrar &lt;code&gt;-rwxr-xr-x&lt;/code&gt; en los scripts.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si el servidor tiene cambios de modo locales y el pull se aborta&lt;/strong&gt;, primero confirma que son solo de modo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git diff --summary
git diff --stat&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El primer comando muestra líneas &lt;code&gt;mode change&lt;/code&gt;; el segundo indica 0 inserciones y 0 eliminaciones si no cambió ninguna línea de contenido. Con eso confirmado, tienes dos salidas:&lt;/p&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Ignorar el modo solo en ese pull&lt;/strong&gt; (no descarta nada): &lt;code&gt;git -c core.fileMode=false pull&lt;/code&gt;. En una prueba con un repositorio desechable, este comando completó el pull que antes se abortaba. Si los modos del servidor coinciden con los que ahora registra el repositorio, &lt;code&gt;git status&lt;/code&gt; debe quedar limpio.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Restaurar los archivos:&lt;/strong&gt; &lt;code&gt;git checkout -- .&lt;/code&gt;. Esto descarta todos los cambios locales, también los de contenido, así que úsalo solo si &lt;code&gt;git diff --stat&lt;/code&gt; no mostró ninguna línea cambiada.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Opcional: una red de seguridad en el script de despliegue.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git pull --ff-only
chmod +x tools/*.py&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Mientras el repositorio tenga el modo &lt;code&gt;100755&lt;/code&gt;, ese &lt;code&gt;chmod&lt;/code&gt; no cambia nada. Pero si alguien añade un script nuevo como &lt;code&gt;100644&lt;/code&gt; y el despliegue lo hace ejecutable, el servidor acumula otra vez cambios de modo locales y puede repetirse el bloqueo del pull. Trátalo como un parche temporal y corrige siempre el modo en el repositorio.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git ls-files -s tools/
git ls-tree HEAD tools/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ambos deben mostrar &lt;code&gt;100755&lt;/code&gt;. En el servidor, después del pull, &lt;code&gt;ls -l&lt;/code&gt; debe mostrar el bit &lt;code&gt;x&lt;/code&gt; y &lt;code&gt;git status&lt;/code&gt; debe estar limpio. Una prueba de humo que comprueba que cada herramienta existe, es ejecutable y arranca (ajusta la opción a lo que acepten tus scripts):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for f in tools/*.py; do
  [ -x &quot;$f&quot; ] || { echo &quot;No ejecutable: $f&quot;; exit 1; }
  &quot;$f&quot; --help &amp;gt; /dev/null || { echo &quot;Falla: $f&quot;; exit 1; }
done
echo &quot;OK&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Para que no vuelva a ocurrir, puedes añadir esta comprobación a tu integración continua. Falla si algún script del repositorio no está registrado como ejecutable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if git ls-files -s -- &#39;tools/*.py&#39; | grep -v &#39;^100755&#39;; then
  echo &quot;Hay scripts sin bit de ejecución&quot;; exit 1
fi&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Quitar el bit ejecutable&lt;/h3&gt;
&lt;p&gt;La opción simétrica es &lt;code&gt;git update-index --chmod=-x archivo&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;El script tiene finales de línea CRLF&lt;/h3&gt;
&lt;p&gt;Es otro problema con síntomas distintos: el error no será &lt;code&gt;Permission denied&lt;/code&gt; sino un fallo con el intérprete indicado en la línea &lt;code&gt;#!&lt;/code&gt;. Para que los scripts mantengan siempre finales de línea LF, usa &lt;code&gt;.gitattributes&lt;/code&gt; con una regla como &lt;code&gt;*.sh text eol=lf&lt;/code&gt;; la documentación de &lt;code&gt;gitattributes&lt;/code&gt; indica que &lt;code&gt;eol=lf&lt;/code&gt; deja en el directorio de trabajo los mismos finales de línea que hay en el índice.&lt;/p&gt;
&lt;h3&gt;No silencies el problema con &lt;code&gt;core.fileMode = false&lt;/code&gt; en el servidor&lt;/h3&gt;
&lt;p&gt;La documentación lo prevé para sistemas de archivos donde el bit ejecutable no es fiable. En un servidor Linux, desactivarlo oculta cambios de modo reales y no arregla el modo registrado en el repositorio. Para comprobar el valor y de dónde viene, usa &lt;code&gt;git config --show-origin core.fileMode&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Ejecutar con el intérprete&lt;/h3&gt;
&lt;p&gt;Si no puedes cambiar el repositorio, &lt;code&gt;python3 tools/build.py&lt;/code&gt; o &lt;code&gt;bash deploy.sh&lt;/code&gt; funcionan sin el bit ejecutable, porque lo que se ejecuta es el intérprete. Solo es un rodeo: las unidades de systemd o los agentes que lanzan el archivo directamente seguirán fallando.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Git registra solo dos modos para archivos normales: &lt;code&gt;100644&lt;/code&gt; y &lt;code&gt;100755&lt;/code&gt;; desde Windows los scripts nuevos entran como &lt;code&gt;100644&lt;/code&gt; porque &lt;code&gt;core.fileMode&lt;/code&gt; suele ser &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Corrige el modo en el repositorio: &lt;code&gt;git update-index --chmod=+x&lt;/code&gt; (o &lt;code&gt;git add --chmod=+x&lt;/code&gt; para archivos nuevos), commit y push.&lt;/li&gt;
  &lt;li&gt;En PowerShell el comodín no se expande: pasa los nombres con &lt;code&gt;git ls-files&lt;/code&gt; y &lt;code&gt;ForEach-Object&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Si un &lt;code&gt;chmod&lt;/code&gt; manual bloquea el &lt;code&gt;git pull&lt;/code&gt;, comprueba con &lt;code&gt;git diff --summary&lt;/code&gt; que solo hay cambios de modo y usa &lt;code&gt;git -c core.fileMode=false pull&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Verifica con &lt;code&gt;git ls-files -s&lt;/code&gt; (modo &lt;code&gt;100755&lt;/code&gt;) y con una prueba de humo en el servidor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-update-index&quot;&gt;Git: &lt;code&gt;git-update-index&lt;/code&gt;, opción &lt;code&gt;--chmod&lt;/code&gt; y &lt;code&gt;core.filemode&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-add&quot;&gt;Git: &lt;code&gt;git-add&lt;/code&gt;, opción &lt;code&gt;--chmod&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;Git: &lt;code&gt;git-config&lt;/code&gt;, entrada &lt;code&gt;core.fileMode&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7913087436983309358/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/git-permission-denied-por-falta-del-bit.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7913087436983309358'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7913087436983309358'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/git-permission-denied-por-falta-del-bit.html' title='Git: Permission denied por falta del bit de ejecución'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-2156391256810382205</id><published>2026-11-05T15:00:00.000-05:00</published><updated>2026-11-05T15:00:00.172-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Linux"/><title type='text'>rclone Google Drive sin navegador y client_id propio</title><content type='html'>&lt;p&gt;Configuras un remote de Google Drive con rclone en un servidor Linux o en un contenedor y el asistente te pide abrir un navegador que ahí no existe. Si ya tenías el remote funcionando, quizá lo que ves es este aviso al ejecutar cualquier comando:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NOTICE: gdrive: This remote uses rclone&#39;s shared Google Drive client_id, which is being retired and will stop working during 2026. Create your own client_id to avoid interruption: https://rclone.org/drive/#making-your-own-client-id&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;También pueden aparecer errores de límite de tasa (&lt;code&gt;rateLimitExceeded&lt;/code&gt;) al copiar muchos archivos y tokens que dejan de funcionar a los siete días. Aquí autorizamos rclone desde otro equipo con navegador y creamos un &lt;code&gt;client_id&lt;/code&gt; propio, que resuelve el aviso, los límites y la caducidad.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;OAuth necesita un navegador.&lt;/strong&gt; La autorización de Drive se completa iniciando sesión en una página de Google. En un servidor sin navegador, la documentación de rclone propone ejecutar &lt;code&gt;rclone authorize&lt;/code&gt; en una máquina que sí lo tenga y pegar el resultado en el servidor.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El &lt;code&gt;client_id&lt;/code&gt; compartido se está retirando.&lt;/strong&gt; Si dejas el campo vacío, rclone usa un &lt;code&gt;client_id&lt;/code&gt; común a todos sus usuarios. La documentación de rclone indica que se retirará y dejará de funcionar durante 2026, y recomienda crear uno propio. El propio asistente avisa de que ese valor interno tiene bajo rendimiento: Google aplica un límite de consultas por segundo a cada &lt;code&gt;client_id&lt;/code&gt;, y el compartido lo usan muchas personas a la vez.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Los errores de límite de tasa.&lt;/strong&gt; La documentación de la API de Drive describe &lt;code&gt;rateLimitExceeded&lt;/code&gt; (códigos 403 y 429) y recomienda reintentar con retroceso exponencial. En tu salida el motivo puede aparecer con otro formato (por ejemplo, en mayúsculas); lo importante es que sea un 403 o 429 de límite de tasa.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Los tokens de siete días.&lt;/strong&gt; Si creas tu propio &lt;code&gt;client_id&lt;/code&gt; pero dejas la pantalla de consentimiento de OAuth en estado &quot;Testing&quot;, la documentación de OAuth de Google indica que el refresh token que se emite vence en 7 días.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;h3&gt;Parte 1: autorizar un servidor sin navegador&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea el remote en el servidor.&lt;/strong&gt; Ejecuta &lt;code&gt;rclone config&lt;/code&gt;, elige &lt;code&gt;n&lt;/code&gt; (New remote), ponle un nombre (por ejemplo &lt;code&gt;gdrive&lt;/code&gt;) y escribe &lt;code&gt;drive&lt;/code&gt; como tipo. Si ya creaste tu propio &lt;code&gt;client_id&lt;/code&gt; (parte 2), indícalo con su &lt;code&gt;client_secret&lt;/code&gt;; si no, déjalos vacíos. Elige el alcance &lt;code&gt;1&lt;/code&gt; (acceso completo), responde &lt;code&gt;n&lt;/code&gt; a la configuración avanzada y &lt;code&gt;n&lt;/code&gt; cuando pregunte si quieres autenticar con un navegador (&lt;code&gt;Use web browser to automatically authenticate rclone with remote?&lt;/code&gt;; en versiones anteriores, &lt;code&gt;Use auto config?&lt;/code&gt;).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Copia el comando que te muestra rclone.&lt;/strong&gt; El asistente imprime algo como esto y se queda esperando en &lt;code&gt;config_token&amp;gt;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Execute the following on the machine with the web browser (same rclone
version recommended):
	rclone authorize &quot;drive&quot; &quot;eyJ...(texto generado por rclone)...&quot;
Then paste the result.&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Copia tal cual el comando de tu versión. En versiones anteriores era simplemente &lt;code&gt;rclone authorize &quot;drive&quot;&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Ejecútalo en un equipo con navegador y rclone&lt;/strong&gt; (la documentación recomienda la misma versión en ambos). Se abre el navegador, inicias sesión y aceptas el acceso. La terminal imprime el resultado entre las marcas &lt;code&gt;Paste the following into your remote machine ---&amp;gt;&lt;/code&gt; y &lt;code&gt;&amp;lt;---End paste&lt;/code&gt;. Es un JSON con tu token.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pégalo en el servidor&lt;/strong&gt; en el prompt &lt;code&gt;config_token&amp;gt;&lt;/code&gt; y confirma con &lt;code&gt;y&lt;/code&gt; cuando rclone muestre la configuración. Si pregunta por una unidad compartida (Shared Drive), responde que no, salvo que la uses. Después comprueba:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rclone lsd gdrive:&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Debe listar las carpetas de la raíz de tu Drive.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Trata ese JSON como una contraseña.&lt;/strong&gt; No lo pegues en chats, tickets ni registros: da acceso a tu Drive hasta que se revoque. Si se filtra, revoca el acceso de rclone desde la configuración de seguridad de tu cuenta de Google (la ubicación exacta del menú cambia con el tiempo) y vuelve a autorizar.&lt;/p&gt;
&lt;p&gt;Alternativa en bash: leer el token sin mostrarlo en pantalla y añadir el remote al final del archivo que indica &lt;code&gt;rclone config file&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;read -r -s -p &quot;Token JSON: &quot; TOKEN; echo
umask 077
CONF=&quot;$(rclone config file | tail -n 1)&quot;
mkdir -p &quot;$(dirname &quot;$CONF&quot;)&quot;
printf &#39;[gdrive]\ntype = drive\nscope = drive\ntoken = %s\n&#39; &quot;$TOKEN&quot; &amp;gt;&amp;gt; &quot;$CONF&quot;
unset TOKEN&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Parte 2: crear tu propio client_id&lt;/h3&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Proyecto y API.&lt;/strong&gt; En la consola de Google Cloud, crea o elige un proyecto y habilita la API de Google Drive.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pantalla de consentimiento.&lt;/strong&gt; Configura el nombre de la aplicación, un correo de soporte y la audiencia. Con Google Workspace, &quot;Internal&quot; limita el uso a los usuarios de tu organización; con una cuenta personal normalmente eliges &quot;External&quot;. En ese caso, si la dejas en &quot;Testing&quot;, los tokens caducan a los 7 días (con un máximo de 100 usuarios de prueba), así que publícala (&quot;In production&quot;) en el apartado de audiencia. Si el botón aparece deshabilitado, rclone indica añadir en Branding una página de inicio, una política de privacidad y un dominio autorizado. Para uso personal, normalmente no necesitas pasar la verificación de Google. Con &quot;Internal&quot;, comprueba que el estado no sea &quot;Testing&quot;: la regla de los 7 días está definida para ese estado.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Alcances.&lt;/strong&gt; En Data Access, agrega los que lista la documentación de rclone: &lt;code&gt;docs&lt;/code&gt;, &lt;code&gt;drive&lt;/code&gt; y &lt;code&gt;drive.metadata.readonly&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cliente OAuth.&lt;/strong&gt; En Credentials, crea un ID de cliente OAuth de tipo &quot;Desktop app&quot; y guarda el ID y el secreto.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Usa el nuevo cliente en rclone.&lt;/strong&gt; En el servidor ejecuta &lt;code&gt;rclone config&lt;/code&gt;, edita el remote (&lt;code&gt;e&lt;/code&gt;) y escribe tu &lt;code&gt;client_id&lt;/code&gt; y &lt;code&gt;client_secret&lt;/code&gt;. Cuando pregunte si quieres renovar o reemplazar el token existente, responde que sí y, cuando pregunte por la autenticación con navegador, responde &lt;code&gt;n&lt;/code&gt;. Si no reemplazas el token, el remote seguiría usando el emitido para el cliente anterior.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Autoriza desde el equipo con navegador&lt;/strong&gt; con el comando que imprime el asistente o con la forma documentada en la ayuda de &lt;code&gt;rclone authorize&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rclone authorize &quot;drive&quot; &quot;&amp;lt;CLIENT_ID&amp;gt;&quot; &quot;&amp;lt;CLIENT_SECRET&amp;gt;&quot;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Al probarlo con un &lt;code&gt;client_id&lt;/code&gt; propio, el asistente de rclone 1.74 imprimió un comando con un bloque en base64 que contiene el &lt;code&gt;client_id&lt;/code&gt;, el &lt;code&gt;client_secret&lt;/code&gt; y el alcance. Base64 no es cifrado: trata también ese comando como un secreto. Pega el JSON resultante en el servidor, como en la parte 1.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Si generas el archivo &lt;code&gt;rclone.conf&lt;/code&gt; desde Python, desactiva la interpolación de &lt;code&gt;ConfigParser&lt;/code&gt;. Con la configuración por defecto, un signo &lt;code&gt;%&lt;/code&gt; dentro de un valor (por ejemplo, en un token o un secreto) provoca errores:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import configparser
from pathlib import Path

ruta = Path(&quot;/ruta/a/rclone.conf&quot;)   # la que muestra: rclone config file
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(ruta)
token_json = &quot;...&quot;   # el JSON que devolvió rclone authorize
cfg[&quot;gdrive&quot;] = {&quot;type&quot;: &quot;drive&quot;, &quot;scope&quot;: &quot;drive&quot;, &quot;token&quot;: token_json}
with ruta.open(&quot;w&quot;) as f:
    cfg.write(f)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sin &lt;code&gt;interpolation=None&lt;/code&gt;, un valor como &lt;code&gt;abc%def&lt;/code&gt; lanza &lt;code&gt;InterpolationSyntaxError&lt;/code&gt; al leerlo y &lt;code&gt;ValueError&lt;/code&gt; al escribirlo. &lt;code&gt;ConfigParser&lt;/code&gt; tampoco conserva los comentarios del archivo al reescribirlo: haz una copia antes.&lt;/p&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;rclone lsd gdrive:
rclone config show gdrive | grep &#39;^client_id&#39;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
  &lt;li&gt;El aviso &lt;code&gt;shared Google Drive client_id&lt;/code&gt; ya no aparece, y el listado funciona en cada remote que hayas migrado.&lt;/li&gt;
  &lt;li&gt;La línea &lt;code&gt;client_id&lt;/code&gt; no está vacía. Ojo: &lt;code&gt;rclone config show&lt;/code&gt; imprime también el token; filtra la salida como arriba y no compartas la terminal.&lt;/li&gt;
  &lt;li&gt;Pasados 7 días, el remote sigue autenticando: la aplicación no quedó en &quot;Testing&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Como referencia de rendimiento, en una medición puntual descargando unos 600 archivos, el tiempo pasó de unos 6 minutos con el &lt;code&gt;client_id&lt;/code&gt; compartido a unos 81 segundos con uno propio. Es un único caso; el resultado depende de la red, del tamaño de los archivos y de las cuotas de tu cuenta.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Sigue apareciendo &lt;code&gt;rateLimitExceeded&lt;/code&gt; con tu propio client_id&lt;/h3&gt;
&lt;p&gt;Un &lt;code&gt;client_id&lt;/code&gt; propio da un límite propio, pero el límite sigue existiendo. La documentación de rclone menciona una cuota por defecto de unas 10 transacciones por segundo y recomienda no superarla. Puedes reducir la presión con la opción global &lt;code&gt;--tpslimit&lt;/code&gt; y bajar el número de transferencias en paralelo. Si el error es &quot;User rate limit exceeded&quot;, la documentación sugiere esperar al menos 24 horas antes de reintentar.&lt;/p&gt;
&lt;h3&gt;El token deja de funcionar a la semana&lt;/h3&gt;
&lt;p&gt;Revisa el estado de publicación de la pantalla de consentimiento (debe ser &quot;In production&quot;) y repite la autorización. El mensaje de rclone suele mencionar &lt;code&gt;invalid_grant&lt;/code&gt;. Google lista además otras causas de un refresh token inválido: que el usuario revoque el acceso o que el token lleve seis meses sin usarse.&lt;/p&gt;
&lt;h3&gt;Alternativas a pegar el token&lt;/h3&gt;
&lt;p&gt;La documentación de rclone describe otras dos vías: copiar el archivo de configuración desde el equipo con navegador (&lt;code&gt;rclone config file&lt;/code&gt; indica su ubicación) o reenviar el puerto 53682 por SSH entre ambos equipos para completar la autorización con el navegador de tu equipo. Las cuentas de servicio (&lt;code&gt;service_account_file&lt;/code&gt;) son otra opción para Workspace, con requisitos propios que no se cubren aquí.&lt;/p&gt;
&lt;p&gt;Recuerda que las preguntas del asistente y el comando de &lt;code&gt;rclone authorize&lt;/code&gt; varían según la versión: si algo no coincide con este artículo, sigue el comando que muestra tu propio rclone.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;En un servidor sin navegador, responde que no a la autenticación con navegador, ejecuta el &lt;code&gt;rclone authorize&lt;/code&gt; que te muestra el asistente en un equipo con navegador y pega el JSON en &lt;code&gt;config_token&amp;gt;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Ese JSON es un secreto: no lo compartas y revoca el acceso si se filtra.&lt;/li&gt;
  &lt;li&gt;El &lt;code&gt;client_id&lt;/code&gt; compartido de rclone se está retirando durante 2026; crea el tuyo (proyecto, API de Drive, pantalla de consentimiento y cliente &quot;Desktop app&quot;).&lt;/li&gt;
  &lt;li&gt;Una app External en &quot;Testing&quot; emite tokens que caducan a los 7 días: publícala o usa &quot;Internal&quot; si tienes Workspace.&lt;/li&gt;
  &lt;li&gt;Si escribes &lt;code&gt;rclone.conf&lt;/code&gt; con Python, usa &lt;code&gt;ConfigParser(interpolation=None)&lt;/code&gt; para que el &lt;code&gt;%&lt;/code&gt; no rompa los valores.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://rclone.org/drive/#making-your-own-client-id&quot;&gt;rclone: Google Drive, sección &quot;Making your own client_id&quot;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rclone.org/remote_setup/&quot;&gt;rclone: Remote setup (configuración en una máquina sin navegador)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.google.com/identity/protocols/oauth2&quot;&gt;Google: uso de OAuth 2.0 y caducidad de refresh tokens&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/2156391256810382205/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/rclone-google-drive-sin-navegador-y.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/2156391256810382205'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/2156391256810382205'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/rclone-google-drive-sin-navegador-y.html' title='rclone Google Drive sin navegador y client_id propio'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-6601429677954182374</id><published>2026-11-05T09:00:00.000-05:00</published><updated>2026-11-05T09:00:00.173-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><title type='text'>Cloudflare: redirigir www al dominio raíz (301)</title><content type='html'>&lt;p&gt;Tu sitio responde en &lt;code&gt;example.com&lt;/code&gt;, pero al escribir &lt;code&gt;www.example.com&lt;/code&gt; el navegador no encuentra nada:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl -I https://www.example.com/
curl: (6) Could not resolve host: www.example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En el navegador verás un error de DNS, por ejemplo &lt;code&gt;DNS_PROBE_FINISHED_NXDOMAIN&lt;/code&gt; en Chrome. Lo que quieres es que &lt;code&gt;www&lt;/code&gt; redirija con un 301 al dominio raíz, que todo vaya por HTTPS y, de paso, tener una dirección como &lt;code&gt;info&lt;/code&gt; en tu dominio sin montar un servidor de correo. Este artículo lo resuelve con Cloudflare. El ejemplo supone un sitio servido con Workers o Pages y un dominio &lt;code&gt;.app&lt;/code&gt;, pero el procedimiento aplica a cualquier dominio cuyo DNS esté en Cloudflare.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;No existe un registro DNS para &lt;code&gt;www&lt;/code&gt;.&lt;/strong&gt; Si el nombre no resuelve, la petición nunca llega a Cloudflare y ninguna regla puede actuar sobre ella.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Las reglas de redirección solo actúan sobre nombres con proxy.&lt;/strong&gt; La documentación de Cloudflare indica que tanto Single Redirects como Bulk Redirects requieren que los registros DNS del dominio pasen por el proxy (la nube naranja). Un registro &quot;solo DNS&quot; (nube gris) resuelve directamente a tu servidor y Cloudflare no ve la petición.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Un nombre que solo redirige no necesita servidor.&lt;/strong&gt; Cloudflare propone crear un registro con proxy que apunte a una dirección marcador, que no enruta tráfico a ningún origen. Para IPv6 la documentación indica la dirección &lt;code&gt;100::&lt;/code&gt;. El proxy recibe la petición y aplica la redirección.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;En un dominio &lt;code&gt;.app&lt;/code&gt; el HTTPS es obligatorio.&lt;/strong&gt; Google Registry indica que &lt;code&gt;.app&lt;/code&gt; está en la lista de precarga de HSTS, de modo que los navegadores compatibles solo se conectan por HTTPS. Los certificados de Universal SSL de Cloudflare cubren el dominio raíz y los subdominios de primer nivel, y se emiten para los registros con proxy, así que &lt;code&gt;www&lt;/code&gt; queda cubierto.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea el registro DNS de &lt;code&gt;www&lt;/code&gt;.&lt;/strong&gt; En el panel de Cloudflare, abre DNS &amp;gt; Records &amp;gt; Add record y completa:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Type:   AAAA
Name:   www
IPv6:   100::
Proxy:  Proxied (nube naranja)&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Guarda el registro. La documentación de Cloudflare ofrece también una dirección equivalente para registros A, por si prefieres IPv4; búscala en la página enlazada en las referencias.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea la regla de redirección.&lt;/strong&gt; Ve a Rules &amp;gt; Redirect Rules (la ruta exacta del menú puede variar) y usa la plantilla &quot;Redirect from WWW to root&quot;, o crea la regla a mano con los valores que muestra la documentación:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;When incoming requests match (wildcard pattern)
  Request URL:  https://www.*

Then
  Target URL:             https://${1}
  Status code:            301
  Preserve query string:  activado&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Con esta regla, &lt;code&gt;https://www.example.com/blog/?a=1&lt;/code&gt; redirige a &lt;code&gt;https://example.com/blog/?a=1&lt;/code&gt;. La propia documentación aclara que las peticiones por HTTP y las que no empiezan por &lt;code&gt;www&lt;/code&gt; no se modifican con esa regla, por eso necesitas el paso siguiente.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fuerza HTTPS en el borde.&lt;/strong&gt; Ve a SSL/TLS &amp;gt; Edge Certificates y activa &quot;Always Use HTTPS&quot;. La opción no aparece si el modo de cifrado es &quot;Off&quot;. Con ambas piezas, &lt;code&gt;http://www.example.com/&lt;/code&gt; pasa primero a HTTPS y después al dominio raíz, es decir, puede haber dos saltos.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Evita el bucle &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;.&lt;/strong&gt; Cloudflare recomienda no hacer redirecciones en el servidor de origen. Si el modo SSL/TLS es &quot;Flexible&quot;, Cloudflare habla con tu origen por HTTP, y si el origen redirige todo a HTTPS, ambos se devuelven la petición sin fin. La solución es quitar esa redirección del origen o cambiar el modo a &quot;Full&quot; o &quot;Full (strict)&quot; (esto exige un certificado en el origen). Si tu aplicación en Workers o Pages también redirige desde su código, deja cada redirección en un solo lugar.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Activa Email Routing para recibir correo en tu dominio.&lt;/strong&gt; En el panel de Cloudflare abre Email Routing (la ubicación en el menú ha cambiado con el tiempo) y selecciona tu dominio. Cloudflare te muestra los registros DNS que va a añadir en el dominio raíz y los crea al completar la configuración:&lt;/p&gt;
    &lt;ul&gt;
      &lt;li&gt;tres registros MX que apuntan a servidores de enrutamiento de Cloudflare (&lt;code&gt;route1&lt;/code&gt;, &lt;code&gt;route2&lt;/code&gt; y &lt;code&gt;route3&lt;/code&gt; bajo &lt;code&gt;mx.cloudflare.net&lt;/code&gt;);&lt;/li&gt;
      &lt;li&gt;un registro TXT de SPF, cuyo valor en la documentación es &lt;code&gt;v=spf1 include:_spf.mx.cloudflare.net ~all&lt;/code&gt;;&lt;/li&gt;
      &lt;li&gt;un registro TXT de DKIM.&lt;/li&gt;
    &lt;/ul&gt;
    &lt;p&gt;Si el dominio ya tenía registros MX, la documentación indica que Email Routing requiere los MX de Cloudflare, así que debes quitar o actualizar los existentes (y no se puede usar a la vez con servidores de correo externos). Si ya tenías un SPF, fusiónalo en un solo registro en lugar de crear un segundo.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Añade y verifica la dirección de destino.&lt;/strong&gt; En Destination Addresses, escribe la bandeja real donde quieres recibir el correo y abre el mensaje de verificación que te envía Cloudflare para confirmarla. Las reglas permanecen desactivadas hasta que el destino esté verificado.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea la regla de enrutamiento.&lt;/strong&gt; En Routing Rules, selecciona &quot;Create routing rule&quot;, escribe &lt;code&gt;info&lt;/code&gt; en Email pattern, selecciona tu dominio, define la acción &quot;Send to an email&quot; y el destino verificado. Guarda.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Email Routing sirve para recibir y reenviar correo entrante. Según la documentación, el envío saliente es otra función aparte; para responder desde la dirección &lt;code&gt;info&lt;/code&gt; necesitas otro servicio.&lt;/p&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Comprueba la redirección de &lt;code&gt;www&lt;/code&gt; sin que influya la caché del navegador:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -sI &quot;https://www.example.com/blog/?a=1&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La respuesta debe empezar por &lt;code&gt;HTTP/2 301&lt;/code&gt; (o &lt;code&gt;HTTP/1.1 301&lt;/code&gt;, según el cliente) e incluir una cabecera &lt;code&gt;location&lt;/code&gt; con &lt;code&gt;https://example.com/blog/?a=1&lt;/code&gt;. Para seguir toda la cadena desde HTTP y contar los saltos:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -sIL -o /dev/null -w &quot;%{http_code} %{url_effective} %{num_redirects}\n&quot; &quot;http://www.example.com/blog/?a=1&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El resultado final debe ser &lt;code&gt;200&lt;/code&gt; con la URL del dominio raíz. Con la regla de la plantilla es esperable ver dos saltos. En Windows PowerShell usa &lt;code&gt;curl.exe&lt;/code&gt; en lugar de &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Para el correo, comprueba que los registros existen:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nslookup -type=MX example.com
nslookup -type=TXT example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En PowerShell también sirve &lt;code&gt;Resolve-DnsName example.com -Type MX&lt;/code&gt;. Debes ver los tres MX de Cloudflare y un TXT con &lt;code&gt;include:_spf.mx.cloudflare.net&lt;/code&gt;. Por último, envía un mensaje a la dirección &lt;code&gt;info&lt;/code&gt; de tu dominio desde una cuenta distinta de la de destino (algunos proveedores descartan correo que parece venir de la misma cuenta) y revisa también la carpeta de spam.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;La redirección no se ejecuta&lt;/h3&gt;
&lt;p&gt;Revisa que el registro de &lt;code&gt;www&lt;/code&gt; exista y tenga la nube naranja. Si está en &quot;solo DNS&quot;, la petición va directa al origen y la regla se omite. Revisa también que la regla coincida: la de la plantilla solo acepta URL que empiezan por &lt;code&gt;https://www.&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Sigue saliendo &lt;code&gt;DNS_PROBE_FINISHED_NXDOMAIN&lt;/code&gt; justo después de crear el registro&lt;/h3&gt;
&lt;p&gt;Las respuestas negativas de DNS pueden permanecer en caché durante un tiempo. Espera unos minutos, prueba con otro resolvedor o desde otra red antes de tocar la configuración.&lt;/p&gt;
&lt;h3&gt;El navegador sigue redirigiendo tras cambiar la regla&lt;/h3&gt;
&lt;p&gt;Los navegadores pueden conservar una redirección permanente. Prueba con &lt;code&gt;curl&lt;/code&gt; o en una ventana privada.&lt;/p&gt;
&lt;h3&gt;Advertencia de certificado en un subdominio más profundo&lt;/h3&gt;
&lt;p&gt;Universal SSL cubre el dominio raíz y los subdominios de primer nivel. Un nombre como &lt;code&gt;a.b.example.com&lt;/code&gt; necesita otro tipo de certificado.&lt;/p&gt;
&lt;h3&gt;Redirigir al revés (de la raíz a &lt;code&gt;www&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Cloudflare documenta el caso inverso con la misma estructura: la regla coincide con &lt;code&gt;https://example.com/*&lt;/code&gt; y redirige a &lt;code&gt;https://www.example.com/${1}&lt;/code&gt;. Elige una sola dirección canónica y no combines ambas reglas.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;www&lt;/code&gt; no resuelve porque no tiene registro DNS; crea un registro AAAA &lt;code&gt;www&lt;/code&gt; con contenido &lt;code&gt;100::&lt;/code&gt; y proxy activado.&lt;/li&gt;
  &lt;li&gt;Las reglas de redirección de Cloudflare solo actúan sobre nombres con proxy (nube naranja).&lt;/li&gt;
  &lt;li&gt;Usa la regla &quot;Redirect from WWW to root&quot; (301, conservando la cadena de consulta) y activa &quot;Always Use HTTPS&quot;; en un dominio &lt;code&gt;.app&lt;/code&gt; el HTTPS es obligatorio.&lt;/li&gt;
  &lt;li&gt;Evita el bucle de redirecciones: no redirijas en el origen y no uses modo &quot;Flexible&quot; si el origen fuerza HTTPS.&lt;/li&gt;
  &lt;li&gt;Email Routing crea los MX y el SPF, exige verificar la dirección de destino y solo gestiona correo entrante.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.cloudflare.com/rules/url-forwarding/examples/redirect-www-to-root/&quot;&gt;Cloudflare Docs: Redirect from WWW to root&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.cloudflare.com/fundamentals/manage-domains/redirect-domain/&quot;&gt;Cloudflare Docs: Redirect a domain (registro DNS marcador con proxy)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.cloudflare.com/email-routing/get-started/enable-email-routing/&quot;&gt;Cloudflare Docs: Enable Email Routing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/6601429677954182374/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/cloudflare-redirigir-www-al-dominio.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6601429677954182374'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6601429677954182374'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/cloudflare-redirigir-www-al-dominio.html' title='Cloudflare: redirigir www al dominio raíz (301)'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-4183805720576014195</id><published>2026-11-04T15:00:00.000-05:00</published><updated>2026-11-04T15:00:00.169-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><title type='text'>Search Console: sitemap &quot;No se ha podido obtener&quot;</title><content type='html'>&lt;p&gt;Publicas un sitio nuevo, envías el sitemap en Search Console y el informe muestra un estado en rojo. Al mismo tiempo, AdSense indica que no encuentra un &lt;code&gt;ads.txt&lt;/code&gt; que sí está publicado:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Search Console &amp;gt; Sitemaps
Estado: No se ha podido obtener      (en inglés: Couldn&#39;t fetch)

AdSense &amp;gt; Sitios
ads.txt: No encontrado               (en inglés: Not found)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Son dos avisos con causas distintas que suelen coincidir en un sitio recién creado. Aquí los separamos, comprobamos con &lt;code&gt;curl&lt;/code&gt; que los rastreadores de Google pueden leer los archivos y vemos qué hacer (y qué no tocar) mientras Google reintenta.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;La propiedad de dominio no completa la URL del sitemap.&lt;/strong&gt; Según la ayuda de Search Console, una propiedad de dominio abarca el dominio raíz, todos sus subdominios y tanto &lt;code&gt;http&lt;/code&gt; como &lt;code&gt;https&lt;/code&gt;, así que no hay una única URL base con la que rellenar el campo. En una propiedad con prefijo de URL, la interfaz muestra ese prefijo delante del cuadro y basta con la ruta (por ejemplo, &lt;code&gt;sitemap-index.xml&lt;/code&gt;); en una de dominio debes escribir la URL completa. La interfaz puede cambiar, pero la ayuda indica que el campo &quot;Add a new sitemap&quot; espera la URL del archivo.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&quot;No se ha podido obtener&quot; no siempre es un error permanente.&lt;/strong&gt; La ayuda explica que significa que Google no pudo recuperar el archivo, y enumera causas habituales: un bloqueo en &lt;code&gt;robots.txt&lt;/code&gt;, un error 404 o un problema temporal del servidor. También dice que algunos errores son transitorios, que Google reintenta durante unos días y que deja de intentarlo si el archivo sigue sin estar disponible. En sitios nuevos el estado puede aparecer justo después de enviar el sitemap aunque el archivo responda bien; la documentación no da una causa específica para ese caso.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AdSense todavía no volvió a revisar el archivo.&lt;/strong&gt; En AdSense, el estado &quot;No encontrado&quot; significa que no halló un &lt;code&gt;ads.txt&lt;/code&gt; en su última comprobación. La ayuda indica que los cambios pueden tardar unos días en reflejarse y hasta un mes si el sitio genera pocas solicitudes de anuncios. El archivo debe estar en la raíz del dominio (&lt;code&gt;/ads.txt&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Envía el sitemap con la URL completa.&lt;/strong&gt; En el informe Sitemaps de una propiedad de dominio, escribe la dirección entera y usa el nombre real de tu archivo (según el generador del sitio puede ser &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;sitemap-index.xml&lt;/code&gt; u otro):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://example.com/sitemap-index.xml&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Ábrela antes en el navegador: debe mostrar XML, no una página de error ni una redirección a otra parte.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba qué recibe un rastreador de Google.&lt;/strong&gt; Pide solo las cabeceras con el user agent de Googlebot y con el del rastreador de AdSense:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I -A &quot;Googlebot&quot; https://example.com/sitemap-index.xml
curl -I -A &quot;Googlebot&quot; https://example.com/robots.txt
curl -I -A &quot;Mediapartners-Google&quot; https://example.com/ads.txt&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La primera línea de cada respuesta debe indicar &lt;code&gt;200&lt;/code&gt; (por ejemplo, &lt;code&gt;HTTP/2 200&lt;/code&gt;). Para ver solo el código y el tipo de contenido, usa esta variante:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s -o /dev/null -w &quot;%{http_code} %{content_type}\n&quot; -A &quot;Googlebot&quot; https://example.com/sitemap-index.xml&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Un &lt;code&gt;301&lt;/code&gt; o &lt;code&gt;302&lt;/code&gt; indica que debes enviar la URL final (revisa la cabecera &lt;code&gt;Location&lt;/code&gt;); un &lt;code&gt;401&lt;/code&gt; o &lt;code&gt;403&lt;/code&gt; suele venir de un cortafuegos o de una protección contra bots; un &lt;code&gt;404&lt;/code&gt;, de un archivo que no se desplegó; un &lt;code&gt;5xx&lt;/code&gt;, del servidor. En Windows PowerShell escribe &lt;code&gt;curl.exe&lt;/code&gt;, porque allí &lt;code&gt;curl&lt;/code&gt; puede ser un alias de &lt;code&gt;Invoke-WebRequest&lt;/code&gt;, que no acepta estas opciones.&lt;/p&gt;
    &lt;p&gt;Esta prueba tiene un límite: cualquiera puede enviar un user agent falso, y Google identifica a sus rastreadores también por la IP y el DNS inverso, así que una protección que valide la IP puede tratar distinto a &lt;code&gt;curl&lt;/code&gt; y al rastreador real. Google tampoco documenta qué rastreador descarga &lt;code&gt;ads.txt&lt;/code&gt;; &lt;code&gt;Mediapartners-Google&lt;/code&gt; es el de AdSense, por lo que la prueba es razonable pero no concluyente.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Revisa &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/strong&gt; No debe bloquear el sitemap ni &lt;code&gt;ads.txt&lt;/code&gt;. Un archivo mínimo que permite todo y anuncia el sitemap:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User-agent: *
Allow: /

Sitemap: https://example.com/sitemap-index.xml&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Según la documentación de Google, un &lt;code&gt;robots.txt&lt;/code&gt; que responde 4xx (salvo 429) se trata como si no existiera, pero uno que responde 5xx hace que Google deje de rastrear el sitio durante un tiempo: un error de servidor en ese archivo es peor que no tenerlo. Además, &lt;code&gt;Mediapartners-Google&lt;/code&gt; ignora el grupo &lt;code&gt;*&lt;/code&gt; y solo obedece los grupos dirigidos a su propio nombre.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Haz una prueba en vivo en Inspección de URLs.&lt;/strong&gt; La ayuda del informe Sitemaps recomienda ejecutar la prueba en vivo y comprobar que &quot;Page fetch&quot; aparece como &quot;Successful&quot;; a diferencia de &lt;code&gt;curl&lt;/code&gt;, usa el rastreo real de Google. Si pasa, usa &quot;Solicitar indexación&quot; en dos o tres páginas clave. Google aclara que no garantiza la indexación y que hay un límite diario de solicitudes.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reenvía solo si corregiste algo.&lt;/strong&gt; Si no había nada que arreglar, deja pasar unas horas o días: Google reintenta por su cuenta.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Prepara el &lt;code&gt;ads.txt&lt;/code&gt; con el fragmento de AdSense.&lt;/strong&gt; En AdSense, abre Sitios, elige el sitio y copia el fragmento de &lt;code&gt;ads.txt&lt;/code&gt; que te ofrece, en lugar de escribir tu identificador a mano. Guárdalo como texto plano en la raíz del sitio, comprueba que &lt;code&gt;https://example.com/ads.txt&lt;/code&gt; se muestra en el navegador y usa &quot;Check for updates&quot; en Sitios para pedir una nueva comprobación.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;No quites el registro TXT de verificación del DNS.&lt;/strong&gt; Una propiedad de dominio se verifica solo por DNS (salvo las alojadas por Google, como Blogger). La ayuda de Search Console indica que debes mantener ese registro aunque la verificación ya haya tenido éxito: Google lo vuelve a comprobar periódicamente y, si ya no lo encuentra, avisa y, pasado un período de gracia, caducan los permisos de la propiedad.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Los tres comandos &lt;code&gt;curl&lt;/code&gt; del paso 2 responden &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;En Search Console &amp;gt; Sitemaps, el estado del archivo pasa a &quot;Correcto&quot; (Success) y aparece el número de páginas descubiertas. Puede tardar horas o días.&lt;/li&gt;
  &lt;li&gt;En AdSense &amp;gt; Sitios, el estado de &lt;code&gt;ads.txt&lt;/code&gt; deja de ser &quot;No encontrado&quot;. La ayuda menciona el estado &quot;Authorized&quot; cuando todo está en orden.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Si tu sitio está en Blogger&lt;/h3&gt;
&lt;p&gt;Blogger genera el sitemap por ti. La ruta habitual es &lt;code&gt;/sitemap.xml&lt;/code&gt; en la dirección de tu blog (en tu propio dominio, si lo usas). No encontré una página de la ayuda oficial de Blogger que lo documente, así que confirma que responde abriéndola en el navegador antes de enviarla. Algunas guías de terceros mencionan también &lt;code&gt;/sitemap-pages.xml&lt;/code&gt; para las páginas estáticas; tampoco pude confirmarlo en documentación oficial, así que abre esa URL y comprueba si existe en tu blog. Según la ayuda de Search Console, en las propiedades alojadas por Google, como Blogger, la verificación es automática si inicias sesión con la cuenta que administra el blog.&lt;/p&gt;
&lt;p&gt;Para &lt;code&gt;ads.txt&lt;/code&gt;, la ayuda de Blogger indica que debes configurar el contenido a mano si el blog se monetiza con proveedores externos o si integraste AdSense manualmente. Los pasos son: Configuración &amp;gt; Monetización, activar &quot;Enable custom ads.txt&quot;, abrir &quot;Custom ads.txt&quot;, pegar el contenido que te da tu proveedor y guardar. Los nombres exactos del menú dependen del idioma. Para comprobarlo, abre la dirección de tu blog seguida de &lt;code&gt;/ads.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&quot;Tiene errores&quot; no es lo mismo que &quot;No se ha podido obtener&quot;&lt;/h3&gt;
&lt;p&gt;Con &quot;Has errors&quot;, Google sí pudo descargar el sitemap, pero contiene errores; las URL que se leyeron bien siguen en cola de rastreo. Abre el detalle del informe para ver cada error.&lt;/p&gt;
&lt;h3&gt;Propiedad equivocada&lt;/h3&gt;
&lt;p&gt;La ayuda advierte de que el sitemap no aparece si miras la propiedad equivocada (por ejemplo, al confundir &lt;code&gt;http&lt;/code&gt; con &lt;code&gt;https&lt;/code&gt;, o &lt;code&gt;www&lt;/code&gt; con la versión sin &lt;code&gt;www&lt;/code&gt;). Una propiedad de dominio cubre todas esas variantes; aun así, las URL dentro del sitemap deben ser absolutas y usar el host canónico.&lt;/p&gt;
&lt;h3&gt;¿Hace falta un sitemap?&lt;/h3&gt;
&lt;p&gt;Google indica que no es obligatorio: un sitio de unas 500 páginas o menos y bien enlazado puede prescindir de él, y enviarlo no garantiza que todo se rastree e indexe.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;En una propiedad de dominio, escribe la URL completa del sitemap; no se completa sola.&lt;/li&gt;
  &lt;li&gt;&quot;No se ha podido obtener&quot; puede ser transitorio: Google reintenta durante unos días. Descarta primero un 404, un bloqueo en &lt;code&gt;robots.txt&lt;/code&gt; o un error 5xx.&lt;/li&gt;
  &lt;li&gt;Comprueba con &lt;code&gt;curl -I -A &quot;Googlebot&quot;&lt;/code&gt; y &lt;code&gt;-A &quot;Mediapartners-Google&quot;&lt;/code&gt; que el sitemap, &lt;code&gt;robots.txt&lt;/code&gt; y &lt;code&gt;ads.txt&lt;/code&gt; responden 200, y confirma con la prueba en vivo de Inspección de URLs.&lt;/li&gt;
  &lt;li&gt;AdSense puede tardar días (hasta un mes en sitios con poco tráfico de anuncios) en reconocer el &lt;code&gt;ads.txt&lt;/code&gt;; usa &quot;Check for updates&quot;.&lt;/li&gt;
  &lt;li&gt;Nunca elimines el registro TXT de verificación del DNS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://support.google.com/webmasters/answer/7451001&quot;&gt;Ayuda de Search Console: informe Sitemaps (estados, reintentos y envío)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://support.google.com/adsense/answer/12171612&quot;&gt;Ayuda de AdSense: estado &quot;No encontrado&quot; de ads.txt y cómo corregirlo&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://support.google.com/blogger/answer/1269077&quot;&gt;Ayuda de Blogger: ads.txt personalizado en Configuración &amp;gt; Monetización&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/4183805720576014195/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/search-console-sitemap-no-se-ha-podido.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4183805720576014195'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4183805720576014195'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/search-console-sitemap-no-se-ha-podido.html' title='Search Console: sitemap &quot;No se ha podido obtener&quot;'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-4634594996500458810</id><published>2026-11-04T09:00:00.000-05:00</published><updated>2026-11-04T09:00:00.122-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><title type='text'>GitHub Actions: startup_failure en todos los workflows</title><content type='html'>&lt;p&gt;Haces push a &lt;code&gt;main&lt;/code&gt; y el despliegue no se actualiza. En la pestaña Actions, todas las ejecuciones de todos los workflows, en todas las ramas, aparecen como fallidas sin haber ejecutado ni un solo paso. Con la CLI de GitHub, el campo &lt;code&gt;conclusion&lt;/code&gt; de esas ejecuciones vale &lt;code&gt;startup_failure&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gh run list --limit 10 --json databaseId,workflowName,headBranch,event,status,conclusion&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El YAML no ha cambiado y es válido, así que reintentar las ejecuciones tampoco cambia el resultado: la causa no está en el archivo. Como tampoco hay un error en tu código, es fácil tardar días en notar que el despliegue dejó de actualizarse.&lt;/p&gt;
&lt;p&gt;Este artículo explica qué suele estar detrás de este patrón (todos los workflows fallan al arrancar), con una causa que a veces se pasa por alto: la facturación de la cuenta. También cómo diagnosticarlo y cómo recuperar el despliegue.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La documentación de GitHub no define &lt;code&gt;startup_failure&lt;/code&gt; en una página propia: no aparece en la referencia de la API de ejecuciones de workflows ni en la guía de solución de problemas de workflows. La &lt;a href=&quot;https://cli.github.com/manual/gh_run_list&quot;&gt;CLI de GitHub&lt;/a&gt; sí lo incluye entre los valores que acepta &lt;code&gt;gh run list --status&lt;/code&gt;. Por los casos reportados en la comunidad, designa ejecuciones que fallaron al arrancar, antes de ejecutar tus pasos. No es una lista completa de causas, pero estas son las que se pueden sustentar:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Un problema de facturación o de límite de gasto de la cuenta.&lt;/strong&gt; La guía oficial de solución de problemas indica que definir un presupuesto (&lt;em&gt;budget&lt;/em&gt;) de Actions puede desbloquear de inmediato los workflows que fallan por errores de facturación o de almacenamiento. La documentación de facturación añade que, sin un método de pago válido, el uso se bloquea cuando se agota la cuota incluida. Según la &lt;a href=&quot;https://docs.github.com/en/billing/how-tos/set-up-budgets&quot;&gt;documentación de presupuestos&lt;/a&gt;, si se agota uno que tiene activada la opción de detener el uso, se bloquea el uso adicional (en su ejemplo, los runners alojados por GitHub, hasta el siguiente ciclo de facturación o hasta aumentar el presupuesto). Y si un pago vence por problemas de facturación, las funciones de pago de la cuenta quedan bloqueadas. En hilos de la comunidad de GitHub se reporta, en la página de la ejecución, un mensaje que dice que el job no se inició porque fallaron pagos recientes o porque hay que aumentar el límite de gasto.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Un archivo de workflow con errores de validación.&lt;/strong&gt; Es una causa reportada en la comunidad (YAML o claves inválidas, por ejemplo). Como el defecto está en un archivo concreto, afecta a ese workflow y no a todos los demás.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;La pista para distinguirlos es el alcance. Si falla un único workflow, revisa su archivo. Si fallan todos, incluidos los que no tocas desde hace meses y los de ramas antiguas, el problema está en la cuenta y no en el YAML.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma el alcance con la CLI.&lt;/strong&gt; Lista las ejecuciones recientes y filtra por el estado:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gh run list --limit 20 --json databaseId,workflowName,headBranch,event,status,conclusion
gh run list --status startup_failure --limit 20&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si todos los workflows y todas las ramas aparecen en &lt;code&gt;startup_failure&lt;/code&gt;, sigue con el paso 2.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Abre una ejecución afectada y lee el mensaje.&lt;/strong&gt; Desde la CLI:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gh run view ID_DE_EJECUCION --web&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;También puedes abrirla desde la pestaña Actions. Si el mensaje habla de pagos fallidos o de límite de gasto, pasa al paso 3.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Revisa la facturación de la cuenta.&lt;/strong&gt; En una cuenta personal, abre &lt;code&gt;github.com/settings/billing&lt;/code&gt;; en una organización, entra en su página &lt;strong&gt;Billing &amp;amp; Licensing&lt;/strong&gt;. Comprueba lo siguiente:&lt;/p&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Método de pago:&lt;/strong&gt; que no haya vencido y que el último cargo no se haya rechazado. Según la &lt;a href=&quot;https://docs.github.com/en/billing/how-tos/troubleshooting/declined-card&quot;&gt;documentación sobre tarjetas rechazadas&lt;/a&gt;, tras un cargo rechazado GitHub envía un correo con el motivo y da unos días para resolverlo antes de volver a intentar el cobro.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Restricciones del banco:&lt;/strong&gt; esa misma página señala que algunos bancos internacionales restringen las transacciones internacionales, de comercio electrónico y recurrentes automáticas. Si el cargo falla sin otra explicación, consulta con tu banco.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Presupuestos:&lt;/strong&gt; en &lt;strong&gt;Budgets and alerts&lt;/strong&gt;, mira si hay un presupuesto de Actions con la opción de detener el uso al alcanzar el límite.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Cuenta bloqueada:&lt;/strong&gt; si la cuenta quedó bloqueada por un pago vencido, actualiza el método de pago. La documentación indica que se desbloquea sola cuando un pago se procesa con éxito, y que el proceso puede tardar hasta 24 horas.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cuando el pago esté resuelto, lanza una ejecución nueva.&lt;/strong&gt; Basta con un push o un pull request. Para poder lanzar el despliegue a mano, añade &lt;code&gt;workflow_dispatch&lt;/code&gt; al disparador y fusiona el cambio en la rama predeterminada (según la &lt;a href=&quot;https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow&quot;&gt;documentación de ejecución manual&lt;/a&gt;, el workflow debe estar en esa rama para recibir el evento):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:
  push:
    branches: [main]
  workflow_dispatch:&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Después, desde la CLI (el nombre es el del workflow, o su archivo):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gh workflow run &quot;Deploy&quot; --ref main&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Mientras el bloqueo siga activo, es de esperar que ni un push nuevo ni &lt;code&gt;gh run rerun&lt;/code&gt; cambien el resultado, porque la causa no está en el workflow. Conviene añadir &lt;code&gt;workflow_dispatch&lt;/code&gt; antes de necesitarlo.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;gh run list --workflow &quot;Deploy&quot; --branch main --limit 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Las ejecuciones nuevas deben terminar con &lt;code&gt;conclusion&lt;/code&gt; igual a &lt;code&gt;success&lt;/code&gt; en lugar de &lt;code&gt;startup_failure&lt;/code&gt;. Después, comprueba el efecto real del despliegue: que el servicio sirve la versión nueva (por ejemplo, por el identificador del build o la fecha de reinicio de los contenedores), no solo que el workflow esté en verde. Si el bloqueo era de facturación y se corrigió, es lo esperable: el mismo workflow, sin cambios, vuelve a ejecutarse.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;No se crea ninguna ejecución&lt;/h3&gt;
&lt;p&gt;Es otro síntoma, con otras causas. La guía de solución de problemas menciona que un workflow desactivado no responde a sus disparadores, que los filtros de rama, etiqueta o ruta pueden omitir la creación de la ejecución, que los workflows de &lt;code&gt;pull_request&lt;/code&gt; no se ejecutan si el pull request tiene un conflicto de fusión, y que eventos como &lt;code&gt;schedule&lt;/code&gt; solo se ejecutan desde la rama predeterminada.&lt;/p&gt;
&lt;h3&gt;Solo un workflow en &lt;code&gt;startup_failure&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Revisa su archivo: según los casos reportados en la comunidad, un error de validación puede hacer que la ejecución termine así. Abre la ejecución y lee el mensaje que muestra la página. Si no ayuda, prueba con un workflow mínimo en una rama de pruebas para ver si el fallo depende del archivo o de la cuenta.&lt;/p&gt;
&lt;h3&gt;Prevenir el siguiente bloqueo&lt;/h3&gt;
&lt;p&gt;La documentación de presupuestos describe alertas al 75 %, 90 % y 100 % del presupuesto, por correo y como aviso en GitHub, y la de facturación de Actions describe avisos por correo cuando el uso incluido llega al 90 % y al 100 %. Actívalas, y añade una notificación (correo o chat) cuando falle el workflow de despliegue, para no descubrir el problema días después.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;startup_failure&lt;/code&gt; en todos los workflows y ramas, con el YAML intacto, apunta a un problema de cuenta y no a tu código.&lt;/li&gt;
  &lt;li&gt;GitHub documenta que la facturación puede bloquear el uso de Actions: método de pago ausente o vencido, presupuesto agotado o cuenta bloqueada por un pago vencido.&lt;/li&gt;
  &lt;li&gt;Revisa &lt;code&gt;github.com/settings/billing&lt;/code&gt; (o Billing &amp;amp; Licensing en una organización): método de pago, cargos rechazados y presupuestos.&lt;/li&gt;
  &lt;li&gt;Cuando se resuelva, lanza una ejecución nueva (push, pull request o &lt;code&gt;gh workflow run&lt;/code&gt; con &lt;code&gt;workflow_dispatch&lt;/code&gt;); un reintento no cambia la causa mientras siga el bloqueo.&lt;/li&gt;
  &lt;li&gt;Verifica con &lt;code&gt;gh run list&lt;/code&gt; que la conclusión sea &lt;code&gt;success&lt;/code&gt; y que el servicio sirva la versión nueva.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/how-tos/troubleshoot-workflows&quot;&gt;GitHub Docs: solución de problemas de workflows (presupuestos y errores de facturación)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/billing/concepts/product-billing/github-actions&quot;&gt;GitHub Docs: facturación de GitHub Actions&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/billing/how-tos/troubleshooting/locked-account&quot;&gt;GitHub Docs: cuenta bloqueada por problemas de facturación&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/4634594996500458810/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/github-actions-startupfailure-en-todos.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4634594996500458810'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4634594996500458810'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/github-actions-startupfailure-en-todos.html' title='GitHub Actions: startup_failure en todos los workflows'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-5432603270176047965</id><published>2026-11-03T15:00:00.000-05:00</published><updated>2026-11-03T15:00:00.117-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="JavaScript"/><category scheme="http://www.blogger.com/atom/ns#" term="Node.js"/><title type='text'>Node: Cannot find module con un script fuera del proyecto</title><content type='html'>&lt;p&gt;Escribes un script rápido para probar algo (una consulta a la base de datos, una conexión WebSocket) y lo guardas en una carpeta temporal. La dependencia está instalada en tu proyecto y ejecutas el script desde la carpeta del proyecto, pero Node responde con uno de estos errores. En CommonJS (&lt;code&gt;require&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: Cannot find module &#39;pg&#39;
Require stack:
- C:\temp\prueba\test.cjs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En módulos ES (&lt;code&gt;import&lt;/code&gt;, archivos &lt;code&gt;.mjs&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error [ERR_MODULE_NOT_FOUND]: Cannot find package &#39;ws&#39; imported from C:\temp\prueba\test.mjs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica dónde busca Node los paquetes, por qué la carpeta desde la que ejecutas no cuenta, y las cuatro formas de resolverlo.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La documentación de Node explica que, para un nombre de paquete (un identificador que no empieza por &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;./&lt;/code&gt; ni &lt;code&gt;../&lt;/code&gt;), Node parte del &lt;strong&gt;directorio del archivo que lo importa&lt;/strong&gt;, añade &lt;code&gt;/node_modules&lt;/code&gt; y busca ahí. Si no lo encuentra, sube al directorio padre y repite, hasta llegar a la raíz del sistema de archivos. La carpeta desde la que lanzas el comando (el directorio de trabajo) no interviene.&lt;/p&gt;
&lt;p&gt;Para un script en &lt;code&gt;C:\temp\prueba&lt;/code&gt;, Node mira &lt;code&gt;C:\temp\prueba\node_modules&lt;/code&gt;, luego &lt;code&gt;C:\temp\node_modules&lt;/code&gt; y &lt;code&gt;C:\node_modules&lt;/code&gt;. Tu proyecto no está en esa cadena, así que no importa que &lt;code&gt;node_modules&lt;/code&gt; exista en otra parte. La resolución de módulos ES describe el mismo recorrido: busca &lt;code&gt;node_modules/nombre&lt;/code&gt; a partir de la URL del archivo que importa y va subiendo por las carpetas padre. Lo hace Node, no el gestor de paquetes: npm o pnpm solo deciden qué hay dentro de cada &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Los dos sistemas difieren en &lt;code&gt;NODE_PATH&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Dónde busca&lt;/th&gt;&lt;th&gt;CommonJS (&lt;code&gt;require&lt;/code&gt;)&lt;/th&gt;&lt;th&gt;Módulos ES (&lt;code&gt;import&lt;/code&gt;)&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;node_modules&lt;/code&gt; desde la carpeta del archivo y sus padres&lt;/td&gt;&lt;td&gt;sí&lt;/td&gt;&lt;td&gt;sí&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Directorio de trabajo del comando&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;Variable &lt;code&gt;NODE_PATH&lt;/code&gt;&lt;/td&gt;&lt;td&gt;sí, si no se encuentra antes&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;La documentación indica para CommonJS que, si el módulo no se encuentra en otro sitio, Node recorre las rutas de &lt;code&gt;NODE_PATH&lt;/code&gt;, y para módulos ES que &lt;code&gt;NODE_PATH&lt;/code&gt; no forma parte de la resolución de &lt;code&gt;import&lt;/code&gt; y recomienda usar enlaces simbólicos. Las pruebas locales con Node 24 coincidieron con la tabla.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Confirma qué carpetas está mirando Node.&lt;/strong&gt; En un script CommonJS, &lt;code&gt;require.resolve.paths&lt;/code&gt; devuelve las rutas que se buscan para un nombre:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;console.log(require.resolve.paths(&quot;pg&quot;));&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La primera ruta es &lt;code&gt;node_modules&lt;/code&gt; junto al script, seguida de las de sus carpetas padre; ninguna es la de tu proyecto. Para comprobar que la dependencia sí está instalada en el proyecto, ejecuta esto desde su carpeta (en la prueba, con &lt;code&gt;-e&lt;/code&gt; la búsqueda partió del directorio actual):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node -e &quot;console.log(require.resolve(&#39;pg&#39;))&quot;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Debe imprimir una ruta dentro del &lt;code&gt;node_modules&lt;/code&gt; del proyecto. Si falla con &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt;, el paquete no está instalado ahí: instálalo (&lt;code&gt;npm install pg&lt;/code&gt; o el comando equivalente de tu gestor) y repite.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;La opción más simple: coloca el script dentro del proyecto.&lt;/strong&gt; Guárdalo con un nombre temporal, por ejemplo &lt;code&gt;.tmp-prueba.mjs&lt;/code&gt;, en la raíz (o en una subcarpeta) del proyecto, ejecútalo y bórralo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node .tmp-prueba.mjs&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Desde ahí, la cadena de búsqueda sí incluye el &lt;code&gt;node_modules&lt;/code&gt; del proyecto. Antes de confirmar cambios en Git, revisa que el archivo temporal no quede en el commit (por ejemplo, añade el patrón &lt;code&gt;.tmp-*&lt;/code&gt; a &lt;code&gt;.gitignore&lt;/code&gt;).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si el script debe seguir fuera: resuelve desde el proyecto con &lt;code&gt;createRequire&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;module.createRequire&lt;/code&gt; crea una función &lt;code&gt;require&lt;/code&gt; anclada a un archivo (ruta absoluta o URL de archivo). Si la anclas al &lt;code&gt;package.json&lt;/code&gt; del proyecto, busca los paquetes desde esa carpeta. Funciona en CommonJS y en módulos ES:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { createRequire } from &quot;node:module&quot;;
import path from &quot;node:path&quot;;
const raiz = process.env.RAIZ_PROYECTO;
const requireDesdeProyecto = createRequire(path.join(raiz, &quot;package.json&quot;));
const { hola } = requireDesdeProyecto(&quot;mi-paquete&quot;);
console.log(&quot;mjs createRequire:&quot;, hola());&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Ejecútalo indicando la carpeta del proyecto en una variable de entorno (PowerShell, solo para esa sesión):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$env:RAIZ_PROYECTO = &quot;C:\proyectos\mi-app&quot;
node C:\temp\prueba\crear.mjs&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En CommonJS el código es el mismo, cambiando la primera línea por &lt;code&gt;const { createRequire } = require(&quot;node:module&quot;);&lt;/code&gt;. Si necesitas un paquete como módulo ES, resuelve la ruta con &lt;code&gt;createRequire&lt;/code&gt; e impórtala con &lt;code&gt;import()&lt;/code&gt; mediante una URL de archivo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { pathToFileURL } from &quot;node:url&quot;;
import { createRequire } from &quot;node:module&quot;;
import path from &quot;node:path&quot;;
const raiz = process.env.RAIZ_PROYECTO;
const req = createRequire(path.join(raiz, &quot;package.json&quot;));
const ruta = req.resolve(&quot;mi-paquete&quot;);
const paquete = await import(pathToFileURL(ruta).href);
console.log(&quot;mjs import(resolve):&quot;, paquete.default.hola());&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Un matiz: &lt;code&gt;req.resolve&lt;/code&gt; usa las reglas de &lt;code&gt;require&lt;/code&gt;. En paquetes que declaran entradas distintas para &lt;code&gt;require&lt;/code&gt; e &lt;code&gt;import&lt;/code&gt; en su campo &lt;code&gt;exports&lt;/code&gt;, puede devolver la entrada de &lt;code&gt;require&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Solo en CommonJS: usa &lt;code&gt;NODE_PATH&lt;/code&gt;.&lt;/strong&gt; Apunta a la carpeta &lt;code&gt;node_modules&lt;/code&gt; del proyecto. En PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$env:NODE_PATH = &quot;C:\proyectos\mi-app\node_modules&quot;
node C:\temp\prueba\test.cjs
Remove-Item Env:NODE_PATH&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En Bash:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NODE_PATH=/home/usuario/mi-app/node_modules node /tmp/prueba/test.cjs&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Según la documentación, varias rutas se separan con &lt;code&gt;;&lt;/code&gt; en Windows y con &lt;code&gt;:&lt;/code&gt; en el resto de plataformas. Este mecanismo no sirve para &lt;code&gt;import&lt;/code&gt; (ver más abajo), y la propia documentación indica que hoy es menos necesario.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Para CommonJS y módulos ES: enlaza o instala junto al script.&lt;/strong&gt; Un enlace llamado &lt;code&gt;node_modules&lt;/code&gt; al lado del script hace que la búsqueda lo encuentre. En Windows se puede usar una unión (&lt;em&gt;junction&lt;/em&gt;); en la prueba se creó desde una sesión de PowerShell sin permisos de administrador. En Linux o macOS, un enlace simbólico:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New-Item -ItemType Junction -Path C:\temp\prueba\node_modules -Target C:\proyectos\mi-app\node_modules&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;ln -s /home/usuario/mi-app/node_modules /tmp/prueba/node_modules&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En la prueba con Windows, la unión hizo que funcionaran tanto el script CommonJS como el de módulos ES. La variante con &lt;code&gt;ln -s&lt;/code&gt; sigue el mismo principio, pero no se probó. Al terminar, elimina solo el enlace, nunca su destino: &lt;code&gt;(Get-Item C:\temp\prueba\node_modules).Delete()&lt;/code&gt; quitó el enlace y dejó intacto el paquete original. La alternativa es instalar la dependencia en la carpeta del script (&lt;code&gt;npm install pg&lt;/code&gt; allí), a costa de duplicarla.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Con cualquiera de las opciones, ejecuta el script que fallaba desde la carpeta del proyecto y desde otra distinta; el resultado debe ser el mismo, porque el directorio de trabajo no influye:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node --version
node C:\temp\prueba\crear.mjs&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En la reproducción local (Node 24, un paquete de prueba en el &lt;code&gt;node_modules&lt;/code&gt; de un proyecto de juguete), estos fueron los resultados:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Script de CommonJS y de módulos ES en una carpeta hermana, ejecutados desde el proyecto: &lt;code&gt;Cannot find module&lt;/code&gt; y &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Los mismos scripts copiados dentro del proyecto: se ejecutaron bien.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;createRequire&lt;/code&gt; (en &lt;code&gt;.cjs&lt;/code&gt; y &lt;code&gt;.mjs&lt;/code&gt;) y &lt;code&gt;import()&lt;/code&gt; con la ruta resuelta: bien.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;NODE_PATH&lt;/code&gt;: bien en CommonJS; el script &lt;code&gt;.mjs&lt;/code&gt; siguió fallando.&lt;/li&gt;
  &lt;li&gt;Unión de Windows al &lt;code&gt;node_modules&lt;/code&gt; del proyecto: bien en ambos.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;NODE_PATH&lt;/code&gt; definido y un mensaje engañoso en &lt;code&gt;import&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Con &lt;code&gt;NODE_PATH&lt;/code&gt; apuntando al proyecto, el script &lt;code&gt;.mjs&lt;/code&gt; falló con el mismo &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt; y añadió la pista &lt;code&gt;Did you mean to import &quot;mi-paquete/index.js&quot;?&lt;/code&gt;. Esa sugerencia no funciona: probar &lt;code&gt;import &quot;mi-paquete/index.js&quot;&lt;/code&gt; falló igual, porque la resolución de &lt;code&gt;import&lt;/code&gt; ignora &lt;code&gt;NODE_PATH&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;Cannot find module&lt;/code&gt; frente a &lt;code&gt;Cannot find package&lt;/code&gt; en &lt;code&gt;import&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Con un paquete, el mensaje dice &lt;code&gt;Cannot find package &#39;x&#39; imported from ...&lt;/code&gt;. Con una ruta relativa sin extensión (&lt;code&gt;import &quot;./otro&quot;&lt;/code&gt;) dice &lt;code&gt;Cannot find module &#39;...\otro&#39; imported from ...&lt;/code&gt;. La documentación de módulos ES indica que la resolución por defecto no añade extensiones: escribe &lt;code&gt;./otro.js&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Monorepos&lt;/h3&gt;
&lt;p&gt;Por la misma regla de recorrido hacia arriba, un script dentro de &lt;code&gt;packages/api&lt;/code&gt; encuentra las dependencias en el &lt;code&gt;node_modules&lt;/code&gt; de la raíz del repositorio si no están en &lt;code&gt;packages/api/node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Códigos de error&lt;/h3&gt;
&lt;p&gt;Según la documentación de errores de Node, &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt; es el código que usa el cargador de CommonJS y &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt; el de módulos ES, para un módulo que no se pudo resolver.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Node busca los paquetes en &lt;code&gt;node_modules&lt;/code&gt; a partir de la carpeta del archivo que los importa y sube por las carpetas padre; el directorio de trabajo no cuenta.&lt;/li&gt;
  &lt;li&gt;Lo más simple es colocar el script dentro del proyecto (por ejemplo &lt;code&gt;.tmp-prueba.mjs&lt;/code&gt;) y borrarlo después.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;createRequire&lt;/code&gt; anclado al &lt;code&gt;package.json&lt;/code&gt; del proyecto resuelve los paquetes desde allí, en CommonJS y en módulos ES.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;NODE_PATH&lt;/code&gt; solo funciona con &lt;code&gt;require&lt;/code&gt;, no con &lt;code&gt;import&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Un enlace &lt;code&gt;node_modules&lt;/code&gt; junto al script (unión en Windows, enlace simbólico en Linux o macOS) o una instalación local también lo resuelven.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/modules.html&quot;&gt;Node.js: módulos CommonJS (carga desde carpetas &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;NODE_PATH&lt;/code&gt;, &lt;code&gt;require.resolve.paths&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/esm.html&quot;&gt;Node.js: módulos ECMAScript (algoritmo de resolución y diferencias con CommonJS)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/module.html&quot;&gt;Node.js: API &lt;code&gt;module&lt;/code&gt; (&lt;code&gt;module.createRequire&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/5432603270176047965/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/node-cannot-find-module-con-un-script.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/5432603270176047965'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/5432603270176047965'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/node-cannot-find-module-con-un-script.html' title='Node: Cannot find module con un script fuera del proyecto'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-6886051197863919873</id><published>2026-11-03T09:00:00.000-05:00</published><updated>2026-11-03T09:00:00.171-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="DevOps"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Git"/><title type='text'>Git: rama divergida y conflictos tras squash and merge</title><content type='html'>&lt;p&gt;Trabajas con ramas permanentes (por ejemplo &lt;code&gt;feature&lt;/code&gt; hacia &lt;code&gt;develop&lt;/code&gt;, luego &lt;code&gt;qa&lt;/code&gt; y por último &lt;code&gt;main&lt;/code&gt;) y fusionas cada PR con &quot;Squash and merge&quot;. Después de fusionar, aparecen tres síntomas:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;La rama local figura como divergida de &lt;code&gt;develop&lt;/code&gt; aunque el contenido es idéntico.&lt;/li&gt;
  &lt;li&gt;El siguiente PR desde la misma rama lista archivos que ya habías fusionado, o informa conflictos.&lt;/li&gt;
  &lt;li&gt;Un PR de &lt;code&gt;qa&lt;/code&gt; hacia &lt;code&gt;main&lt;/code&gt; marca conflictos en archivos que &lt;code&gt;main&lt;/code&gt; no ha tocado desde la última promoción.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;El primer síntoma se ve así en Git. Ocurre, por ejemplo, con una rama creada con &lt;code&gt;git switch -c feature/x origin/develop&lt;/code&gt;, que por defecto hereda &lt;code&gt;origin/develop&lt;/code&gt; como rama de seguimiento, después de que &lt;code&gt;develop&lt;/code&gt; recibió el squash de sus 2 commits:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;On branch feature/x
Your branch and &#39;origin/develop&#39; have diverged,
and have 2 and 1 different commits each, respectively.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica por qué ocurre, cómo comprobar que no se perdió nada y cómo resincronizar la rama para que los PR siguientes vuelvan a mostrar solo tus cambios.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Según la documentación de GitHub, &quot;Squash and merge&quot; combina todos los commits del PR en un único commit nuevo sobre la rama base. Ese commit tiene su propio hash y solo un padre: los commits originales de tu rama (&lt;code&gt;F1&lt;/code&gt;, &lt;code&gt;F2&lt;/code&gt;) nunca llegan a la rama base.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;develop:  B ---------------- S     (S: un commit nuevo, sin relación con F1 ni F2)
           \
feature:    F1 -- F2                (siguen siendo commits distintos de S)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El contenido es el mismo, pero para Git son historias distintas. Git no compara commit por commit; al fusionar, según su documentación, solo considera las dos puntas y la base de fusión (el último ancestro común). Tras el squash, esa base sigue siendo &lt;code&gt;B&lt;/code&gt;, el punto donde la rama se separó. De ahí salen los síntomas:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;GitHub muestra en un PR una diferencia de tres puntos: compara la base de fusión con la punta de la rama. Con la base antigua, los cambios ya fusionados vuelven a aparecer. La propia documentación de GitHub avisa de que, si sigues trabajando en la misma rama y abres otro PR hacia la misma base, los commits que ya combinaste se listan de nuevo y puede que debas resolver los mismos conflictos una y otra vez.&lt;/li&gt;
  &lt;li&gt;Si después de la fusión vuelves a editar las mismas líneas, el cambio de la rama base (&lt;code&gt;S&lt;/code&gt;) y el tuyo parten de la versión antigua &lt;code&gt;B&lt;/code&gt;, así que Git ve dos ediciones distintas de la misma línea y marca conflicto.&lt;/li&gt;
  &lt;li&gt;En &lt;code&gt;develop&lt;/code&gt;, &lt;code&gt;qa&lt;/code&gt; y &lt;code&gt;main&lt;/code&gt;, que se reutilizan en cada ciclo, el efecto se repite en cada promoción: el squash que entra en &lt;code&gt;main&lt;/code&gt; nunca aparece en la historia de &lt;code&gt;qa&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Por la misma razón, las herramientas que comparan commits no sirven para saber si algo ya está integrado: en la prueba, &lt;code&gt;git cherry -v origin/develop HEAD&lt;/code&gt; marcó con &lt;code&gt;+&lt;/code&gt; los dos commits, porque ninguno coincide con el commit de squash. Compara contenido, como en el primer paso de la solución.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba que el contenido es idéntico.&lt;/strong&gt; Justo después de cada squash merge:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git fetch origin
git diff origin/develop --stat&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si no imprime nada, tu rama y &lt;code&gt;develop&lt;/code&gt; tienen exactamente los mismos archivos y puedes resincronizar sin perder trabajo. Si imprime archivos, hay cambios locales que aún no están en &lt;code&gt;develop&lt;/code&gt;: no resincronices a ciegas.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Resincroniza la rama.&lt;/strong&gt; Fusiona &lt;code&gt;origin/develop&lt;/code&gt; en tu rama:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git merge origin/develop&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Como ambos lados contienen los mismos cambios, la fusión se completa sin conflictos (en la reproducción local así fue) y la base de fusión pasa a ser la punta de &lt;code&gt;develop&lt;/code&gt;. Los PR siguientes solo mostrarán los commits nuevos. Si no necesitas conservar los commits antiguos, también puedes crear la rama de nuevo desde el remoto:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git switch -c feature/x-2 origin/develop&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;&lt;code&gt;git reset --hard origin/develop&lt;/code&gt; deja el mismo resultado, pero descarta cualquier cambio local sin guardar; úsalo solo cuando el paso 1 salió vacío y no tienes nada pendiente.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba la fusionabilidad antes de pedir revisión.&lt;/strong&gt; En local, &lt;code&gt;git merge-tree&lt;/code&gt; simula la fusión sin tocar tu directorio de trabajo ni el índice:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git merge-tree --write-tree --name-only origin/develop HEAD&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El código de salida es 0 si la fusión es limpia y 1 si hay conflictos; en ese caso imprime los archivos en conflicto. (Según su documentación, no crea commits ni lee o escribe el directorio de trabajo o el índice; solo genera un objeto de árbol. El modo &lt;code&gt;--write-tree&lt;/code&gt; existe en versiones recientes de Git; la prueba usó la 2.54.) En GitHub, justo después de abrir el PR:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gh pr view NUMERO --json mergeable,mergeStateStatus&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Según la referencia de la API GraphQL, &lt;code&gt;MERGEABLE&lt;/code&gt; indica que se puede fusionar, &lt;code&gt;CONFLICTING&lt;/code&gt; que tiene conflictos y &lt;code&gt;UNKNOWN&lt;/code&gt; que aún se está calculando; en &lt;code&gt;mergeStateStatus&lt;/code&gt;, &lt;code&gt;CLEAN&lt;/code&gt; significa que se puede fusionar y las comprobaciones pasan, y &lt;code&gt;DIRTY&lt;/code&gt; que la fusión no se puede crear sin conflictos. Si ves &lt;code&gt;UNKNOWN&lt;/code&gt;, repite la consulta unos segundos después.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Para un PR de &lt;code&gt;qa&lt;/code&gt; a &lt;code&gt;main&lt;/code&gt; con conflictos, crea una rama de sincronización desde &lt;code&gt;main&lt;/code&gt;.&lt;/strong&gt; Necesitas el SHA del commit de &lt;code&gt;qa&lt;/code&gt; que se promovió la última vez (por ejemplo, el último commit del PR anterior). En Git Bash o WSL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git fetch origin
ABSORBIDO=SHA_DE_QA_DE_LA_ULTIMA_PROMOCION
git switch -c sync/qa-main origin/main
git merge origin/qa

git diff --name-only --diff-filter=U | while IFS= read -r f; do
  actual=$(git rev-parse -q --verify &quot;origin/main:$f&quot;)
  absorbido=$(git rev-parse -q --verify &quot;$ABSORBIDO:$f&quot;)
  if [ -n &quot;$actual&quot; ] &amp;amp;&amp;amp; [ &quot;$actual&quot; = &quot;$absorbido&quot; ]; then
    git checkout --theirs -- &quot;$f&quot; &amp;amp;&amp;amp; git add -- &quot;$f&quot;
  else
    echo &quot;REVISAR a mano: $f&quot;
  fi
done
git commit --no-edit&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La lógica: si el contenido de un archivo en &lt;code&gt;main&lt;/code&gt; es idéntico (mismo hash de blob) al que tenía &lt;code&gt;qa&lt;/code&gt; cuando se promovió por última vez, &lt;code&gt;main&lt;/code&gt; no lo ha cambiado desde entonces y la versión correcta es la de &lt;code&gt;qa&lt;/code&gt;. En una fusión, &lt;code&gt;--theirs&lt;/code&gt; es la rama que fusionas (&lt;code&gt;qa&lt;/code&gt;). Si los hashes difieren, &lt;code&gt;main&lt;/code&gt; tiene cambios propios y debes resolver ese archivo a mano.&lt;/p&gt;
    &lt;p&gt;Evita el atajo &lt;code&gt;git merge -X theirs origin/qa&lt;/code&gt;: según la documentación, resuelve a favor del otro lado todos los trozos en conflicto, incluidos los que contienen una corrección real hecha en &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cambia el método de fusión en las promociones entre ramas permanentes.&lt;/strong&gt; Mientras &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;qa&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt; se fusionen con squash, el problema volverá en el ciclo siguiente. Con &quot;Create a merge commit&quot;, GitHub añade todos los commits de la rama de origen a la base mediante un commit de fusión, de modo que la base de fusión avanza. Mantén &quot;Squash and merge&quot; para &lt;code&gt;feature&lt;/code&gt; → &lt;code&gt;develop&lt;/code&gt;, donde la rama se descarta tras fusionar. Esto exige que el repositorio permita ese método de fusión y deja commits de fusión en la historia.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Tras resincronizar tu rama, el primer número de este comando (commits que solo están en &lt;code&gt;develop&lt;/code&gt;) debe ser 0:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git rev-list --left-right --count origin/develop...HEAD
git diff --stat origin/develop...HEAD&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El segundo comando es lo que verá el PR: en la reproducción local, antes de resincronizar listaba 2 archivos (uno de ellos ya fusionado por el squash) y, después de resincronizar, solo el archivo del commit nuevo. En el mismo caso, &lt;code&gt;git merge-tree&lt;/code&gt; pasó de terminar con código 1 (conflicto en el archivo editado dos veces) a terminar con código 0.&lt;/p&gt;
&lt;p&gt;En la rama de sincronización &lt;code&gt;qa&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt;, comprueba que el árbol resultante coincide con el de &lt;code&gt;qa&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git rev-parse &quot;HEAD^{tree}&quot;
git rev-parse &quot;origin/qa^{tree}&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Los dos hashes deben ser iguales. En PowerShell las comillas son necesarias: sin ellas, &lt;code&gt;HEAD^{tree}&lt;/code&gt; falló en la prueba con Windows PowerShell 5.1. Si &lt;code&gt;main&lt;/code&gt; recibió cambios que &lt;code&gt;qa&lt;/code&gt; no tiene (por ejemplo, una corrección urgente), los árboles diferirán justo en esos archivos; revisa con &lt;code&gt;git diff --stat origin/qa HEAD&lt;/code&gt; que solo aparecen ellos. Por último, una vez abierto el PR de sincronización, &lt;code&gt;gh pr view&lt;/code&gt; debe mostrar &lt;code&gt;MERGEABLE&lt;/code&gt; y, si las comprobaciones pasan, &lt;code&gt;CLEAN&lt;/code&gt;. Esa parte depende de GitHub y no se reprodujo en local: la reproducción usó repositorios locales que simulan el squash con &lt;code&gt;git merge --squash&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;error: the branch &#39;feature/x&#39; is not fully merged&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Después del squash, si la rama remota se borró (y se ejecutó &lt;code&gt;git fetch --prune&lt;/code&gt;), &lt;code&gt;git branch -d feature/x&lt;/code&gt; responde con ese error aunque el contenido esté en &lt;code&gt;develop&lt;/code&gt;: así ocurrió en la prueba con Git 2.54, con &lt;code&gt;develop&lt;/code&gt; actualizada y como rama actual. Comprueba primero que &lt;code&gt;git diff develop feature/x --stat&lt;/code&gt; no imprime nada y entonces usa &lt;code&gt;git branch -D feature/x&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Conflictos de tipo &quot;modificado/eliminado&quot;&lt;/h3&gt;
&lt;p&gt;Si un archivo se creó o se borró en uno de los lados, &lt;code&gt;git rev-parse -q --verify&lt;/code&gt; no devuelve ningún hash para él. El script lo marca como &quot;REVISAR a mano&quot; en lugar de tomar una decisión por ti.&lt;/p&gt;
&lt;h3&gt;El mismo problema en &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;qa&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;No es exclusivo de &lt;code&gt;main&lt;/code&gt;. Cualquier promoción por squash entre dos ramas que siguen vivas acumula la misma divergencia; la receta de la rama de sincronización sirve para cualquier par de ramas.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&quot;Squash and merge&quot; crea un commit nuevo en la rama base; tus commits originales no entran en su historia, aunque el contenido sea idéntico.&lt;/li&gt;
  &lt;li&gt;Tras cada squash, ejecuta &lt;code&gt;git fetch origin&lt;/code&gt; y &lt;code&gt;git diff origin/develop --stat&lt;/code&gt;: vacío significa mismo contenido.&lt;/li&gt;
  &lt;li&gt;Resincroniza enseguida con &lt;code&gt;git merge origin/develop&lt;/code&gt; (o recrea la rama desde el remoto) para que los PR muestren solo tus archivos.&lt;/li&gt;
  &lt;li&gt;Para &lt;code&gt;qa&lt;/code&gt; → &lt;code&gt;main&lt;/code&gt;, fusiona &lt;code&gt;origin/qa&lt;/code&gt; en una rama creada desde &lt;code&gt;origin/main&lt;/code&gt; y resuelve por archivo comparando hashes de blob; verifica con los hashes de árbol.&lt;/li&gt;
  &lt;li&gt;Para romper el ciclo, usa &quot;Create a merge commit&quot; en las promociones entre ramas permanentes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/about-merge-methods-on-github&quot;&gt;GitHub: acerca de los métodos de fusión (squash, merge commit y sus advertencias)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-comparing-branches-in-pull-requests&quot;&gt;GitHub: comparación de ramas en pull requests (diferencia de tres puntos)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-merge-tree&quot;&gt;Git: &lt;code&gt;git merge-tree&lt;/code&gt; (&lt;code&gt;--write-tree&lt;/code&gt;, &lt;code&gt;--name-only&lt;/code&gt; y códigos de salida)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/6886051197863919873/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/git-rama-divergida-y-conflictos-tras.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6886051197863919873'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/6886051197863919873'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/git-rama-divergida-y-conflictos-tras.html' title='Git: rama divergida y conflictos tras squash and merge'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-8971748723558111923</id><published>2026-11-02T15:00:00.000-05:00</published><updated>2026-11-02T15:00:00.122-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="JavaScript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>Zod: Invalid enum value, received &#39;&#39; al leer Excel</title><content type='html'>&lt;p&gt;Subes un archivo de Excel correcto, con una columna opcional que quedó vacía en todas las filas, y el servidor rechaza cada fila. Al validar con Zod aparece este error (Zod 3):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &quot;received&quot;: &quot;&quot;,
    &quot;code&quot;: &quot;invalid_enum_value&quot;,
    &quot;options&quot;: [&quot;a&quot;, &quot;b&quot;],
    &quot;path&quot;: [&quot;tipo&quot;],
    &quot;message&quot;: &quot;Invalid enum value. Expected &#39;a&#39; | &#39;b&#39;, received &#39;&#39;&quot;
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En Zod 4 el mensaje cambia a &lt;code&gt;Invalid option: expected one of &quot;a&quot;|&quot;b&quot;&lt;/code&gt; (con el código &lt;code&gt;invalid_value&lt;/code&gt;), pero la causa es la misma. El esquema parece correcto, porque la columna tiene un valor por defecto:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const Fila = z.object({
  nombre: z.string(),
  tipo: z.enum([&quot;a&quot;, &quot;b&quot;]).default(&quot;a&quot;),
});

const filas = XLSX.utils
  .sheet_to_json(hoja, { defval: &quot;&quot; })
  .map((fila) =&amp;gt; Fila.parse(fila));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica por qué el valor por defecto nunca se aplica, cuándo usar &lt;code&gt;??&lt;/code&gt; y cuándo &lt;code&gt;||&lt;/code&gt;, y cómo normalizar las celdas vacías con y sin Zod.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;El fallo es la suma de tres comportamientos que, por separado, son correctos:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;defval: &quot;&quot;&lt;/code&gt; convierte las celdas vacías en cadenas vacías.&lt;/strong&gt; En la documentación de SheetJS, &lt;code&gt;defval&lt;/code&gt; sustituye los valores &lt;code&gt;null&lt;/code&gt; o &lt;code&gt;undefined&lt;/code&gt;; sin esa opción, las celdas vacías se omiten y la clave no existe en el objeto. Leyendo un &lt;code&gt;.xlsx&lt;/code&gt; con una celda vacía, &lt;code&gt;sheet_to_json(hoja)&lt;/code&gt; devolvió &lt;code&gt;{&quot;nombre&quot;:&quot;ana&quot;}&lt;/code&gt; y, con &lt;code&gt;defval: &quot;&quot;&lt;/code&gt;, &lt;code&gt;{&quot;nombre&quot;:&quot;ana&quot;,&quot;tipo&quot;:&quot;&quot;}&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;??&lt;/code&gt; solo reemplaza &lt;code&gt;null&lt;/code&gt; y &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; Según MDN, la cadena vacía y el 0 son valores válidos para este operador: &lt;code&gt;&quot;&quot; ?? &quot;a&quot;&lt;/code&gt; devuelve &lt;code&gt;&quot;&quot;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;.default()&lt;/code&gt; de Zod solo actúa con &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; La documentación indica que, si la entrada es &lt;code&gt;undefined&lt;/code&gt;, se devuelve el valor por defecto. Una cadena vacía es una cadena, así que pasa al &lt;code&gt;z.enum&lt;/code&gt;, que no la acepta.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Resultado observado con Zod 3.25 y con Zod 4.6 (misma conducta en ambas):&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Entrada para &lt;code&gt;tipo&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Con &lt;code&gt;.default(&quot;a&quot;)&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;clave ausente o &lt;code&gt;undefined&lt;/code&gt;&lt;/td&gt;&lt;td&gt;devuelve &lt;code&gt;&quot;a&quot;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;&quot;&quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;error de enum&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;&lt;code&gt;null&lt;/code&gt; (probado con Zod 4)&lt;/td&gt;&lt;td&gt;error de enum&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Con &lt;a href=&quot;https://github.com/exceljs/exceljs&quot;&gt;ExcelJS&lt;/a&gt; el patrón es distinto. Según su documentación, &lt;code&gt;row.eachCell()&lt;/code&gt; visita solo las celdas no nulas, y con &lt;code&gt;{ includeEmpty: true }&lt;/code&gt; visita también las vacías. En la prueba, el valor de una celda vacía fue &lt;code&gt;null&lt;/code&gt;, no una cadena vacía, de modo que ahí &lt;code&gt;??&lt;/code&gt; sí funciona, pero &lt;code&gt;.default()&lt;/code&gt; tampoco se aplica a &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Normaliza la celda en el punto de lectura.&lt;/strong&gt; Convierte a texto, recorta espacios y sustituye lo vacío por el valor por defecto:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function tipoDesdeCelda(valor: unknown, porDefecto = &quot;a&quot;): string {
  return String(valor ?? &quot;&quot;).trim() || porDefecto;
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Cada operador tiene un trabajo. &lt;code&gt;?? &quot;&quot;&lt;/code&gt; evita que &lt;code&gt;String(null)&lt;/code&gt; produzca el texto &lt;code&gt;&quot;null&quot;&lt;/code&gt;. &lt;code&gt;trim()&lt;/code&gt; trata una celda con solo espacios como vacía. &lt;code&gt;||&lt;/code&gt; reemplaza la cadena vacía resultante, que es justo lo que se busca aquí. El número 0 no se pierde: &lt;code&gt;String(0)&lt;/code&gt; es &lt;code&gt;&quot;0&quot;&lt;/code&gt;, una cadena no vacía.&lt;/p&gt;
    &lt;p&gt;&lt;strong&gt;Regla práctica:&lt;/strong&gt; usa &lt;code&gt;??&lt;/code&gt; cuando &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;&quot;&quot;&lt;/code&gt; o &lt;code&gt;false&lt;/code&gt; son valores legítimos que no debes tocar; usa &lt;code&gt;||&lt;/code&gt; cuando &quot;vacío&quot; significa &quot;usa el valor por defecto&quot; (y convierte antes a texto si el dato puede ser un número).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;O deja que Zod haga la normalización con &lt;code&gt;z.preprocess&lt;/code&gt;.&lt;/strong&gt; Convierte la cadena vacía en &lt;code&gt;undefined&lt;/code&gt; antes del enum, y el &lt;code&gt;.default()&lt;/code&gt; interno ya funciona:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import * as XLSX from &quot;xlsx&quot;;
import { z } from &quot;zod&quot;;

const vacioComoUndefined = (valor: unknown): unknown =&amp;gt;
  typeof valor === &quot;string&quot; &amp;amp;&amp;amp; valor.trim() === &quot;&quot; ? undefined : valor;

export const FilaSchema = z.object({
  nombre: z.string().min(1),
  tipo: z.preprocess(vacioComoUndefined, z.enum([&quot;a&quot;, &quot;b&quot;]).default(&quot;a&quot;)),
});

export type Fila = z.infer&amp;lt;typeof FilaSchema&amp;gt;;

export function leerFilas(hoja: XLSX.WorkSheet): Fila[] {
  const crudas = XLSX.utils.sheet_to_json&amp;lt;Record&amp;lt;string, unknown&amp;gt;&amp;gt;(hoja, { defval: &quot;&quot; });
  return crudas.map((fila) =&amp;gt; FilaSchema.parse(fila));
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Un valor inválido como &lt;code&gt;&quot;x&quot;&lt;/code&gt; sigue rechazándose, que es lo deseable. Evita &lt;code&gt;.catch(&quot;a&quot;)&lt;/code&gt; para este caso: oculta también los errores reales de datos. Tampoco sirve &lt;code&gt;.optional()&lt;/code&gt;, porque &lt;code&gt;&quot;&quot;&lt;/code&gt; no es &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si no necesitas &lt;code&gt;defval&lt;/code&gt;, quítalo.&lt;/strong&gt; Sin esa opción, SheetJS omite las celdas vacías y la clave llega ausente, de modo que &lt;code&gt;.default(&quot;a&quot;)&lt;/code&gt; funciona sin &lt;code&gt;preprocess&lt;/code&gt;. Mantén &lt;code&gt;defval&lt;/code&gt; solo si necesitas que todas las claves existan en cada fila.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Estas pruebas generan un &lt;code&gt;.xlsx&lt;/code&gt; en memoria, lo leen de nuevo y comprueban los cuatro comportamientos que importan:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { test } from &quot;node:test&quot;;
import assert from &quot;node:assert/strict&quot;;
import * as XLSX from &quot;xlsx&quot;;
import { leerFilas, tipoDesdeCelda } from &quot;./leer-excel.ts&quot;;

/** Crea una hoja, la guarda como .xlsx en memoria y la vuelve a leer. */
function hojaDesde(filas: unknown[][]): XLSX.WorkSheet {
  const libro = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(libro, XLSX.utils.aoa_to_sheet(filas), &quot;Hoja1&quot;);
  const archivo = XLSX.write(libro, { type: &quot;buffer&quot;, bookType: &quot;xlsx&quot; });
  return XLSX.read(archivo).Sheets[&quot;Hoja1&quot;];
}

test(&quot;celda vacía usa el valor por defecto&quot;, () =&amp;gt; {
  const filas = leerFilas(hojaDesde([[&quot;nombre&quot;, &quot;tipo&quot;], [&quot;ana&quot;, null], [&quot;luis&quot;, &quot;b&quot;]]));
  assert.deepEqual(filas, [{ nombre: &quot;ana&quot;, tipo: &quot;a&quot; }, { nombre: &quot;luis&quot;, tipo: &quot;b&quot; }]);
});

test(&quot;celda con solo espacios usa el valor por defecto&quot;, () =&amp;gt; {
  const filas = leerFilas(hojaDesde([[&quot;nombre&quot;, &quot;tipo&quot;], [&quot;ana&quot;, &quot;   &quot;]]));
  assert.equal(filas[0].tipo, &quot;a&quot;);
});

test(&quot;un valor inválido sigue rechazándose&quot;, () =&amp;gt; {
  assert.throws(() =&amp;gt; leerFilas(hojaDesde([[&quot;nombre&quot;, &quot;tipo&quot;], [&quot;ana&quot;, &quot;x&quot;]])));
});

test(&quot;tipoDesdeCelda cubre null, undefined, vacío y números&quot;, () =&amp;gt; {
  assert.equal(tipoDesdeCelda(null), &quot;a&quot;);
  assert.equal(tipoDesdeCelda(undefined), &quot;a&quot;);
  assert.equal(tipoDesdeCelda(&quot;&quot;), &quot;a&quot;);
  assert.equal(tipoDesdeCelda(&quot;  b &quot;), &quot;b&quot;);
  assert.equal(tipoDesdeCelda(0), &quot;0&quot;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;node --test leer-excel.test.ts
npx tsc --noEmit&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En la reproducción local (Node 24, &lt;code&gt;xlsx&lt;/code&gt; 0.18.5, Zod 3.25 y Zod 4.6) las cuatro pruebas pasaron y &lt;code&gt;tsc&lt;/code&gt; terminó sin errores. El esquema con &lt;code&gt;z.preprocess&lt;/code&gt; dio el mismo resultado en las dos versiones de Zod. Revisa también, uno por uno, los demás lugares donde tu proyecto lee archivos de Excel o CSV: cualquier parser que use un valor por defecto de cadena vacía y luego &lt;code&gt;??&lt;/code&gt; tiene el mismo problema.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Columnas numéricas: la cadena vacía se convierte en 0&lt;/h3&gt;
&lt;p&gt;Según la documentación de Zod, &lt;code&gt;z.coerce.number()&lt;/code&gt; convierte la entrada con &lt;code&gt;Number(entrada)&lt;/code&gt;, y &lt;code&gt;Number(&quot;&quot;)&lt;/code&gt; es 0, así que una celda vacía pasa como 0 sin ningún error (probado en Zod 3 y 4). Si &quot;vacío&quot; debe significar &quot;sin dato&quot;, pasa primero la entrada por la misma función &lt;code&gt;vacioComoUndefined&lt;/code&gt; y marca el campo como &lt;code&gt;.optional()&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;defval: null&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Con &lt;code&gt;null&lt;/code&gt; en lugar de &lt;code&gt;&quot;&quot;&lt;/code&gt;, &lt;code&gt;.default()&lt;/code&gt; tampoco actúa. En la prueba, un preprocess que convierte tanto &lt;code&gt;null&lt;/code&gt; como las cadenas en blanco en &lt;code&gt;undefined&lt;/code&gt; funcionó en Zod 3 y 4:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const blancoAUndefined = (v: unknown): unknown =&amp;gt;
  v === null || (typeof v === &quot;string&quot; &amp;amp;&amp;amp; v.trim() === &quot;&quot;) ? undefined : v;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ExcelJS: celdas vacías al final de la fila&lt;/h3&gt;
&lt;p&gt;En la prueba, &lt;code&gt;row.eachCell({ includeEmpty: true })&lt;/code&gt; visitó la celda vacía del medio de una fila (valor &lt;code&gt;null&lt;/code&gt;), pero no la celda vacía final, porque la fila no la almacena. Para obtener siempre las columnas por posición usa &lt;code&gt;row.getCell(n).value&lt;/code&gt;, que devuelve &lt;code&gt;null&lt;/code&gt; si no hay valor.&lt;/p&gt;
&lt;h3&gt;Celdas con solo espacios&lt;/h3&gt;
&lt;p&gt;Para el usuario parecen vacías, pero &lt;code&gt;&quot;   &quot; || &quot;a&quot;&lt;/code&gt; devuelve los espacios. Por eso el patrón incluye &lt;code&gt;trim()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;sheet_to_json(hoja, { defval: &quot;&quot; })&lt;/code&gt; produce &lt;code&gt;&quot;&quot;&lt;/code&gt; en las celdas vacías, y ni &lt;code&gt;??&lt;/code&gt; ni &lt;code&gt;.default()&lt;/code&gt; de Zod reaccionan a una cadena vacía.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;??&lt;/code&gt; solo cubre &lt;code&gt;null&lt;/code&gt; y &lt;code&gt;undefined&lt;/code&gt;; úsalo cuando &lt;code&gt;0&lt;/code&gt; o &lt;code&gt;&quot;&quot;&lt;/code&gt; sean valores válidos.&lt;/li&gt;
  &lt;li&gt;Cuando &quot;vacío&quot; significa &quot;valor por defecto&quot;, normaliza con &lt;code&gt;String(x ?? &quot;&quot;).trim() || valorPorDefecto&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Con Zod, usa &lt;code&gt;z.preprocess&lt;/code&gt; para convertir las cadenas en blanco en &lt;code&gt;undefined&lt;/code&gt; antes del &lt;code&gt;.default()&lt;/code&gt;; funciona en Zod 3 y 4.&lt;/li&gt;
  &lt;li&gt;Vigila las columnas numéricas: &lt;code&gt;z.coerce.number()&lt;/code&gt; convierte &lt;code&gt;&quot;&quot;&lt;/code&gt; en 0.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing&quot;&gt;MDN: operador de fusión nula (&lt;code&gt;??&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zod.dev/api&quot;&gt;Zod: referencia de la API (&lt;code&gt;.default()&lt;/code&gt;, &lt;code&gt;z.preprocess&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.sheetjs.com/docs/api/utilities/array/&quot;&gt;SheetJS: utilidades de arreglos (&lt;code&gt;sheet_to_json&lt;/code&gt;, opción &lt;code&gt;defval&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/8971748723558111923/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/zod-invalid-enum-value-received-al-leer.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/8971748723558111923'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/8971748723558111923'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/zod-invalid-enum-value-received-al-leer.html' title='Zod: Invalid enum value, received &#39;&#39; al leer Excel'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-1339568932196834303</id><published>2026-11-02T09:00:00.000-05:00</published><updated>2026-11-02T09:00:00.117-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="JavaScript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>JavaScript: la suma de porcentajes da 0.9999999999999999</title><content type='html'>&lt;p&gt;Tienes un reparto entre varios miembros (porcentajes de participación, cuotas o pesos). La interfaz indica que está completo, pero el motor de validación lo rechaza con un mensaje como este:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: El reparto debe sumar 1 (suma actual: 0.9999999999999999)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La causa se reproduce con diez miembros que reciben 0.1 cada uno:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const partes = Array(10).fill(0.1);
const suma = partes.reduce((acc, p) =&amp;gt; acc + p, 0);

console.log(suma);       // 0.9999999999999999
console.log(suma === 1); // false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica por qué ocurre, cómo validar con una tolerancia adecuada (y por qué &lt;code&gt;Number.EPSILON&lt;/code&gt; no basta para sumas acumuladas) y cómo repartir en unidades enteras para que el total sea exacto.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Según MDN, el tipo &lt;code&gt;Number&lt;/code&gt; de JavaScript es un valor IEEE 754 de doble precisión y 64 bits en formato binario. Una fracción decimal como 0.1 no tiene representación binaria finita, así que se almacena el valor más cercano que cabe en 64 bits. Cada suma vuelve a redondear el resultado y esos errores diminutos se acumulan. El ejemplo clásico es este:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0.1 + 0.2;          // 0.30000000000000004
0.1 + 0.2 === 0.3;  // false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En un reparto, el resultado depende del número de miembros y de la escala. Sumando N veces el valor 100/N (en porcentaje) y N veces ese mismo valor dividido entre 100 (como fracción), Node 24 devolvió esto:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;&lt;th&gt;Miembros (N)&lt;/th&gt;&lt;th&gt;Suma en porcentaje&lt;/th&gt;&lt;th&gt;Suma como fracción&lt;/th&gt;&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;100.00000000000001&lt;/td&gt;&lt;td&gt;1.0000000000000002&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;7&lt;/td&gt;&lt;td&gt;100.00000000000001&lt;/td&gt;&lt;td&gt;1.0000000000000002&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;td&gt;0.9999999999999999&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;11&lt;/td&gt;&lt;td&gt;100.00000000000001&lt;/td&gt;&lt;td&gt;1.0000000000000002&lt;/td&gt;&lt;/tr&gt;
    &lt;tr&gt;&lt;td&gt;13&lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;td&gt;0.9999999999999998&lt;/td&gt;&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Con 3 miembros todo parece correcto y con 11 la suma no es exactamente 1. Como el resultado cambia con N y con la escala, el fallo parece aleatorio, pero es determinista.&lt;/p&gt;
&lt;p&gt;Redondear cada parte a dos decimales tampoco cierra el total: 3 partes de 33.33 suman 99.99 y 6 partes de 16.67 suman 100.02. Eso ya no es un problema de coma flotante sino de redondeo, y se resuelve repartiendo el resto (paso 2).&lt;/p&gt;
&lt;p&gt;Hay un tercer culpable, que no es de precisión. Si el valor viaja entre capas como porcentaje y alguna capa lo divide entre 100 sin que otra lo sepa, dos mitades de 50 % suman 1 con una división (0.5 + 0.5) y 0.01 con dos divisiones (0.005 + 0.005). Una tolerancia no debe tapar ese error (paso 3).&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Compara con una tolerancia en lugar de usar &lt;code&gt;===&lt;/code&gt;.&lt;/strong&gt; Define una constante y úsala en toda la validación:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** Tolerancia absoluta: mucho mayor que el error de coma flotante y mucho menor que 0,01 %. */
export const TOLERANCIA = 1e-6;

export interface ResultadoReparto {
  valido: boolean;
  suma: number;
  mensaje?: string;
}

/** Valida que las participaciones (fracciones entre 0 y 1) sumen 1 dentro de TOLERANCIA. */
export function validarReparto(
  participaciones: ReadonlyArray&amp;lt;number | null | undefined&amp;gt;,
): ResultadoReparto {
  const suma = participaciones.reduce&amp;lt;number&amp;gt;((acc, p) =&amp;gt; acc + (p ?? 0), 0);
  if (!Number.isFinite(suma) || Math.abs(suma - 1) &amp;gt;= TOLERANCIA) {
    return {
      valido: false,
      suma,
      mensaje: `El reparto debe sumar 1 (suma actual: ${suma})`,
    };
  }
  return { valido: true, suma };
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El valor &lt;code&gt;1e-6&lt;/code&gt; es órdenes de magnitud mayor que el error de coma flotante de unas pocas sumas (del orden de 1e-16) y mucho menor que la diferencia más pequeña que una persona puede introducir: 0.01 % equivale a 1e-4 como fracción. Ajústalo a la precisión real de tus datos.&lt;/p&gt;
    &lt;p&gt;&lt;strong&gt;¿Por qué no &lt;code&gt;Number.EPSILON&lt;/code&gt;?&lt;/strong&gt; MDN lo define como la diferencia entre 1 y el siguiente número representable (unos 2.22e-16), lo que lo hace útil para valores cercanos a 1 y para el error de una sola operación, y advierte que no debe usarse sin más como umbral de igualdad. En sumas acumuladas se queda corto. En la prueba, once sumandos de 1/11 se alejan de 1 exactamente en &lt;code&gt;Number.EPSILON&lt;/code&gt;, así que &lt;code&gt;Math.abs(suma - 1) &amp;lt; Number.EPSILON&lt;/code&gt; da &lt;code&gt;false&lt;/code&gt;; y cien sumandos de 0.01 dan 1.0000000000000007, unas tres veces esa diferencia.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reparte en unidades enteras (puntos básicos) con el método del mayor resto.&lt;/strong&gt; Si la interfaz y el motor trabajan con enteros, la suma es exacta por construcción: MDN indica que los enteros se representan sin pérdida entre −(2&lt;sup&gt;53&lt;/sup&gt; − 1) y 2&lt;sup&gt;53&lt;/sup&gt; − 1. Usa 10 000 puntos básicos como 100 %:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/**
 * Reparte `total` unidades enteras (por defecto 10 000 puntos básicos = 100 %)
 * proporcionalmente a `pesos`, con el método del mayor resto.
 * La suma del resultado es exactamente `total`.
 */
export function repartirEnteros(pesos: readonly number[], total = 10_000): number[] {
  const sumaPesos = pesos.reduce((acc, p) =&amp;gt; acc + p, 0);
  if (pesos.length === 0 || !(sumaPesos &amp;gt; 0)) {
    throw new Error(&quot;Se necesita al menos un peso positivo&quot;);
  }
  const cuotas = pesos.map((p) =&amp;gt; (p / sumaPesos) * total);
  const partes = cuotas.map((c) =&amp;gt; Math.floor(c));
  let resto = total - partes.reduce((acc, n) =&amp;gt; acc + n, 0);
  const orden = cuotas
    .map((c, i) =&amp;gt; ({ i, fraccion: c - Math.floor(c) }))
    .sort((a, b) =&amp;gt; b.fraccion - a.fraccion || a.i - b.i);
  for (let k = 0; resto &amp;gt; 0; k = (k + 1) % orden.length) {
    partes[orden[k].i] += 1;
    resto -= 1;
  }
  return partes;
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El método toma la parte entera de cada cuota y entrega las unidades sobrantes, de una en una, a quienes tienen la mayor parte decimal. El desempate por posición lo hace determinista. Con 11 miembros iguales, diez reciben 909 y uno recibe 910: suman 10 000. Convierte a porcentaje solo para mostrar:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** Solo para mostrar: 3333 -&amp;gt; &quot;33,33 %&quot;. */
export function formatearPuntosBasicos(pb: number): string {
  return `${(pb / 100).toFixed(2).replace(&quot;.&quot;, &quot;,&quot;)} %`;
}&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Usa una sola escala y convierte una vez.&lt;/strong&gt; Decide si el dato viaja como fracción (0 a 1), como porcentaje (0 a 100) o como puntos básicos, y refléjalo en el nombre del campo (&lt;code&gt;participacionPb&lt;/code&gt;, &lt;code&gt;fraccion&lt;/code&gt;). Convierte únicamente en los bordes: al recibir lo que escribe el usuario y al mostrarlo. Si la suma sale 0.01 o 0.0001 en lugar de 1, busca la división repetida; no aumentes la tolerancia.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Escribe pruebas que fallarían con &lt;code&gt;===&lt;/code&gt;.&lt;/strong&gt; Estas nueve usan &lt;code&gt;node:test&lt;/code&gt;; los fixtures de los casos 2 y 3 comprueban primero que la comparación estricta realmente falla:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { test } from &quot;node:test&quot;;
import assert from &quot;node:assert/strict&quot;;
import { formatearPuntosBasicos, repartirEnteros, TOLERANCIA, validarReparto } from &quot;./reparto.ts&quot;;

const suma = (xs: number[]) =&amp;gt; xs.reduce((a, b) =&amp;gt; a + b, 0);

test(&quot;1. suma exacta es válida&quot;, () =&amp;gt; {
  assert.equal(validarReparto([0.25, 0.25, 0.25, 0.25]).valido, true);
});

test(&quot;2. diez veces 0.1 es válida aunque la suma no sea exactamente 1&quot;, () =&amp;gt; {
  const partes = Array&amp;lt;number&amp;gt;(10).fill(0.1);
  assert.notEqual(suma(partes), 1); // el fixture realmente falla con ===
  assert.equal(validarReparto(partes).valido, true);
});

test(&quot;3. 1 + EPSILON es válida&quot;, () =&amp;gt; {
  const partes = [0.5, 0.5 + Number.EPSILON];
  assert.notEqual(suma(partes), 1);
  assert.equal(validarReparto(partes).valido, true);
});

test(&quot;4. 50 % es inválida&quot;, () =&amp;gt; {
  const r = validarReparto([0.25, 0.25]);
  assert.equal(r.valido, false);
  assert.match(r.mensaje ?? &quot;&quot;, /debe sumar 1/);
});

test(&quot;5. 150 % es inválida&quot;, () =&amp;gt; {
  assert.equal(validarReparto([0.75, 0.75]).valido, false);
});

test(&quot;6. 0.999 queda fuera de la tolerancia&quot;, () =&amp;gt; {
  assert.ok(Math.abs(0.999 - 1) &amp;gt; TOLERANCIA);
  assert.equal(validarReparto([0.999]).valido, false);
});

test(&quot;7. null y undefined cuentan como 0&quot;, () =&amp;gt; {
  assert.equal(validarReparto([0.5, null, 0.5, undefined]).valido, true);
});

test(&quot;8. sin datos es inválida&quot;, () =&amp;gt; {
  const r = validarReparto([]);
  assert.equal(r.valido, false);
  assert.equal(r.suma, 0);
});

test(&quot;9. 11 miembros suman exactamente 10 000 puntos básicos&quot;, () =&amp;gt; {
  const partes = repartirEnteros(Array&amp;lt;number&amp;gt;(11).fill(1));
  assert.equal(suma(partes), 10_000);
  assert.ok(partes.every((p) =&amp;gt; p === 909 || p === 910));
  assert.equal(formatearPuntosBasicos(partes[0]), &quot;9,10 %&quot;);
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La prueba 6 es la más importante: una tolerancia mal elegida (por ejemplo 0.01) aceptaría sin avisar repartos que de verdad están incompletos.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;node --test reparto.test.ts
npx tsc --noEmit&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Node 24 ejecuta archivos &lt;code&gt;.ts&lt;/code&gt; directamente quitando los tipos, por lo que no necesita un paso de compilación para las pruebas; el segundo comando valida los tipos. En la reproducción local las nueve pruebas pasaron, con 0 fallos, y &lt;code&gt;tsc&lt;/code&gt; terminó con código de salida 0.&lt;/p&gt;
&lt;p&gt;Para ganar confianza en el reparto entero, una comprobación aleatoria con unos 20 000 conjuntos de pesos (entre 1 y 40 miembros, algunos con peso 0) y repartos iguales de 1 a 300 miembros dio siempre una suma de 10 000, con enteros no negativos y cada parte a menos de una unidad de su cuota ideal.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;La suma sale 0.01, 0.0001 o 100&lt;/h3&gt;
&lt;p&gt;No es coma flotante. Es una escala mal aplicada: dividir dos veces entre 100 (o ninguna) en alguna capa. Localiza la conversión duplicada en lugar de ampliar la tolerancia.&lt;/p&gt;
&lt;h3&gt;Importes de dinero&lt;/h3&gt;
&lt;p&gt;El mismo principio aplica: guarda la unidad mínima (centavos) como entero y convierte solo al mostrar.&lt;/p&gt;
&lt;h3&gt;Redondear con &lt;code&gt;toFixed&lt;/code&gt; antes de comparar&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Number(suma.toFixed(6)) === 1&lt;/code&gt; funciona en la mayoría de los casos, pero pasa por una cadena y desplaza el problema al borde del redondeo. La comparación con tolerancia expresa mejor la intención.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;null&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt; y &lt;code&gt;NaN&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;En el validador, &lt;code&gt;p ?? 0&lt;/code&gt; trata &lt;code&gt;null&lt;/code&gt; y &lt;code&gt;undefined&lt;/code&gt; como 0. Un &lt;code&gt;NaN&lt;/code&gt; contamina toda la suma, y &lt;code&gt;Number.isFinite&lt;/code&gt; lo rechaza antes de comparar.&lt;/p&gt;
&lt;h3&gt;Cuando necesitas decimales exactos en muchos cálculos&lt;/h3&gt;
&lt;p&gt;Para repartos basta con enteros. Si tu dominio exige aritmética decimal exacta en operaciones encadenadas, evalúa una biblioteca de decimales en lugar de parchear cada comparación.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Los &lt;code&gt;Number&lt;/code&gt; de JavaScript son binarios de doble precisión: 0.1 no es exacto y &lt;code&gt;===&lt;/code&gt; falla al comparar sumas con 1.&lt;/li&gt;
  &lt;li&gt;Valida con &lt;code&gt;Math.abs(suma - 1) &amp;lt; 1e-6&lt;/code&gt;; &lt;code&gt;Number.EPSILON&lt;/code&gt; es demasiado estricto para sumas acumuladas.&lt;/li&gt;
  &lt;li&gt;Elige una tolerancia mucho menor que la diferencia mínima real de tus datos, para que 0.999 siga siendo un error.&lt;/li&gt;
  &lt;li&gt;Reparte en enteros (puntos básicos) con el método del mayor resto para que el total sea exacto.&lt;/li&gt;
  &lt;li&gt;Define una sola escala y convierte una vez; una suma de 0.01 indica una división repetida, no un problema de precisión.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON&quot;&gt;MDN: &lt;code&gt;Number.EPSILON&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number&quot;&gt;MDN: &lt;code&gt;Number&lt;/code&gt; (formato IEEE 754 y rango de enteros exactos)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/1339568932196834303/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/javascript-la-suma-de-porcentajes-da.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1339568932196834303'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1339568932196834303'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/11/javascript-la-suma-de-porcentajes-da.html' title='JavaScript: la suma de porcentajes da 0.9999999999999999'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-1391504084730669315</id><published>2026-11-01T15:00:00.000-05:00</published><updated>2026-11-01T15:00:00.200-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Docker"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Windows"/><title type='text'>WSL2 se cuelga por falta de memoria: ajusta .wslconfig</title><content type='html'>&lt;p&gt;Llevas un rato compilando proyectos Node.js dentro de WSL 2 con Docker Desktop abierto, y de pronto todo deja de funcionar a la vez:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Los comandos que pasan por &lt;code&gt;wsl.exe&lt;/code&gt; pueden terminar con código de salida 1 y sin ninguna salida.&lt;/li&gt;
  &lt;li&gt;Docker pierde el socket y la CLI responde con un mensaje como este (el texto exacto varía según la versión):&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
  &lt;li&gt;Los servidores de desarrollo que estaban en marcha se cierran solos.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cuando esto ocurre justo después de lanzar dos compilaciones o comprobaciones de tipos en paralelo (por ejemplo, dos &lt;code&gt;next build&lt;/code&gt;), una causa frecuente es que se agotó la memoria de la máquina virtual de WSL 2. Este artículo explica cómo confirmarlo, cómo recuperar el entorno y cómo ajustar los límites en &lt;code&gt;.wslconfig&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;WSL 2 no usa directamente toda la RAM de Windows: ejecuta las distribuciones de Linux dentro de una máquina virtual ligera con un límite de memoria. Según la documentación de Microsoft, el valor por defecto de &lt;code&gt;memory&lt;/code&gt; en el archivo &lt;code&gt;.wslconfig&lt;/code&gt; es el 50 % de la memoria total de Windows, el espacio de intercambio (&lt;code&gt;swap&lt;/code&gt;) por defecto es el 25 % del tamaño de esa memoria redondeado hacia arriba al GB más cercano, y &lt;code&gt;processors&lt;/code&gt; usa todos los procesadores lógicos. Esa configuración se aplica a la máquina virtual que da soporte a todas las distribuciones WSL 2.&lt;/p&gt;
&lt;p&gt;Docker Desktop con el backend de WSL 2 se ejecuta en una distribución propia (&lt;code&gt;docker-desktop&lt;/code&gt;), pero dentro de esa misma máquina virtual, así que contenedores, compilaciones y servidores de desarrollo comparten la misma bolsa de memoria. Node.js no ayuda a contenerla: la opción &lt;code&gt;--max-old-space-size&lt;/code&gt; (en MiB) fija el límite de la zona de memoria &quot;old&quot; del heap de V8 de cada proceso, no un límite global. Dos procesos con 6144 MiB de heap permitido suman 12 GiB solo en heap, más lo que consumen el sistema, Docker y los servidores de desarrollo.&lt;/p&gt;
&lt;p&gt;Cuando la máquina virtual llega a su límite, los procesos pueden terminar de forma abrupta o dejar de responder, y todo lo que depende de ella (&lt;code&gt;wsl.exe&lt;/code&gt;, el socket de Docker, los servidores) puede fallar al mismo tiempo. Es un diagnóstico por descarte, no un mensaje explícito, por eso conviene confirmarlo midiendo la memoria durante la tarea pesada.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reinicia WSL desde Windows.&lt;/strong&gt; Abre PowerShell (no un terminal de WSL) y ejecuta:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wsl --shutdown&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Según la documentación, este comando termina de inmediato todas las distribuciones en ejecución y la máquina virtual ligera de WSL 2. Úsalo con cuidado: se cierran todos los procesos de todas tus distribuciones, así que guarda tu trabajo antes. Después, espera unos segundos (la documentación habla de unos 8 segundos) y comprueba que no queda nada activo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wsl --list --running&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Debe responder &quot;There are no running distributions.&quot; (o el equivalente en tu idioma). Vuelve a abrir Docker Desktop, espera a que el motor aparezca como iniciado y abre un terminal nuevo.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Ejecuta las tareas pesadas de una en una.&lt;/strong&gt; En lugar de lanzar dos compilaciones a la vez, encadénalas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm run typecheck &amp;amp;&amp;amp; npm run build&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Esto evita que dos heaps grandes coincidan en el tiempo.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Sube el límite de la máquina virtual en &lt;code&gt;.wslconfig&lt;/code&gt;.&lt;/strong&gt; El archivo no existe por defecto: debe estar en tu carpeta de usuario, &lt;code&gt;%UserProfile%\.wslconfig&lt;/code&gt;. Puedes crearlo desde PowerShell con:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;notepad $env:USERPROFILE\.wslconfig&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Contenido de ejemplo (ajústalo a la RAM real de tu equipo):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[wsl2]
memory=12GB
swap=8GB
processors=6&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Los tamaños admiten sufijos como &lt;code&gt;GB&lt;/code&gt; o &lt;code&gt;MB&lt;/code&gt;; sin sufijo se interpretan como bytes. No asignes toda la RAM: deja margen para Windows, o será Windows quien se quede sin memoria. Un valor de 12 GB solo tiene sentido en un equipo con bastante más RAM que eso (por ejemplo, 32 GB). Microsoft también recomienda modificar estas opciones desde la aplicación &quot;WSL Settings&quot; del menú Inicio en lugar de editar el archivo a mano.&lt;/p&gt;
    &lt;p&gt;Para aplicar los cambios, vuelve a ejecutar &lt;code&gt;wsl --shutdown&lt;/code&gt; y espera a que WSL se detenga por completo. Si el archivo tiene errores de formato, la documentación indica que WSL arranca igualmente, pero sin aplicar la configuración.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Mantén el heap de Node.js dentro de la memoria de la máquina virtual.&lt;/strong&gt; &lt;code&gt;--max-old-space-size&lt;/code&gt; acepta MiB y se puede pasar mediante &lt;code&gt;NODE_OPTIONS&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export NODE_OPTIONS=--max-old-space-size=4096&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La documentación de Node.js recomienda dejar margen: en una máquina de 2 GiB sugiere 1536 MiB para dejar memoria al resto y evitar el uso de swap. Calcula el valor de modo que la suma de los heaps de los procesos que corren a la vez, más Docker y el sistema, quede por debajo de la memoria asignada.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Vigila el consumo mientras trabajas.&lt;/strong&gt; Dentro de la distribución:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;free -h
ps aux --sort=-%mem | head -n 8
docker stats --no-stream&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;&lt;code&gt;free -h&lt;/code&gt; muestra la memoria total que ve la máquina virtual y cuánta queda libre; &lt;code&gt;htop&lt;/code&gt; da una vista interactiva si lo tienes instalado.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;Tras reiniciar, &lt;code&gt;free -h&lt;/code&gt; dentro de WSL debe mostrar una memoria total cercana al valor de &lt;code&gt;memory&lt;/code&gt; que configuraste (el sistema reserva una parte, así que no coincide exactamente). Si sigue mostrando el valor anterior, el archivo no se aplicó: revisa que se llame &lt;code&gt;.wslconfig&lt;/code&gt; (sin extensión &lt;code&gt;.txt&lt;/code&gt;), que esté en la carpeta de tu usuario y que tenga la sección &lt;code&gt;[wsl2]&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;docker ps&lt;/code&gt; debe responder sin el error de conexión una vez que Docker Desktop indique que el motor está iniciado. Levanta de nuevo los contenedores que necesites.&lt;/li&gt;
  &lt;li&gt;Lanza la tarea pesada que provocó el problema, de una en una, y observa &lt;code&gt;free -h&lt;/code&gt;. Si la memoria libre se acerca a cero y empieza a usarse swap de forma sostenida, sube el límite o reduce el heap de Node.js.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Devolver a Windows la memoria en caché&lt;/h3&gt;
&lt;p&gt;La documentación de Docker Desktop señala que, como WSL 2 asigna memoria de forma dinámica, Docker solo usa lo que necesita, y sugiere activar el ajuste experimental &lt;code&gt;autoMemoryReclaim&lt;/code&gt; (en la sección &lt;code&gt;[experimental]&lt;/code&gt; de &lt;code&gt;.wslconfig&lt;/code&gt;) para que Windows recupere memoria retenida, como la caché de páginas que queda después de construir imágenes. Valores disponibles: &lt;code&gt;disabled&lt;/code&gt;, &lt;code&gt;gradual&lt;/code&gt; y &lt;code&gt;dropCache&lt;/code&gt;. En la documentación actual de Microsoft, &lt;code&gt;dropCache&lt;/code&gt; figura como valor por defecto, así que comprueba qué tienes configurado antes de tocarlo.&lt;/p&gt;
&lt;h3&gt;Detener solo una distribución&lt;/h3&gt;
&lt;p&gt;Si no quieres cerrar todo, &lt;code&gt;wsl --terminate NombreDeLaDistribucion&lt;/code&gt; detiene solo la distribución indicada. La documentación describe &lt;code&gt;wsl --shutdown&lt;/code&gt; como el comando que, además de terminar todas las distribuciones, apaga la máquina virtual ligera de WSL 2, que es lo que necesitas para aplicar cambios de &lt;code&gt;.wslconfig&lt;/code&gt; o reiniciar el entorno.&lt;/p&gt;
&lt;h3&gt;El problema no es la memoria&lt;/h3&gt;
&lt;p&gt;Si &lt;code&gt;wsl.exe&lt;/code&gt; falla y la memoria estaba holgada, revisa otras causas. Una documentada es un error al montar el disco de la distribución, que puede mostrar &quot;An error occurred mounting the distribution disk, it was mounted read-only as a fallback.&quot; En ese caso, la documentación de Microsoft sobre espacio en disco describe cómo repararlo.&lt;/p&gt;
&lt;h3&gt;Si &lt;code&gt;wsl --shutdown&lt;/code&gt; no basta&lt;/h3&gt;
&lt;p&gt;Como último recurso, reinicia Windows. Antes, guarda cualquier trabajo abierto fuera de WSL.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;WSL 2 corre en una máquina virtual con límite de memoria; por defecto, el 50 % de la RAM de Windows.&lt;/li&gt;
  &lt;li&gt;Dos compilaciones con heaps grandes, Docker y servidores de desarrollo pueden agotarla y dejar a &lt;code&gt;wsl.exe&lt;/code&gt; y Docker sin respuesta.&lt;/li&gt;
  &lt;li&gt;Recupera el entorno con &lt;code&gt;wsl --shutdown&lt;/code&gt; desde PowerShell y reabre Docker Desktop.&lt;/li&gt;
  &lt;li&gt;Ejecuta las tareas pesadas de una en una y ajusta &lt;code&gt;memory&lt;/code&gt;, &lt;code&gt;swap&lt;/code&gt; y &lt;code&gt;processors&lt;/code&gt; en &lt;code&gt;%UserProfile%\.wslconfig&lt;/code&gt;, aplicándolos con otro &lt;code&gt;wsl --shutdown&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Mantén &lt;code&gt;--max-old-space-size&lt;/code&gt; dentro de la memoria disponible y vigila el consumo con &lt;code&gt;free -h&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/windows/wsl/wsl-config&quot;&gt;Microsoft: configuración avanzada en WSL (&lt;code&gt;.wslconfig&lt;/code&gt;, &lt;code&gt;wsl.conf&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/windows/wsl/basic-commands&quot;&gt;Microsoft: comandos básicos de WSL (&lt;code&gt;wsl --shutdown&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/cli.html&quot;&gt;Node.js: opciones de línea de comandos (&lt;code&gt;--max-old-space-size&lt;/code&gt;, &lt;code&gt;NODE_OPTIONS&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/1391504084730669315/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2027/01/wsl2-se-cuelga-por-falta-de-memoria.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1391504084730669315'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1391504084730669315'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2027/01/wsl2-se-cuelga-por-falta-de-memoria.html' title='WSL2 se cuelga por falta de memoria: ajusta .wslconfig'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-8249976444756265311</id><published>2026-11-01T09:00:00.000-05:00</published><updated>2026-11-01T09:00:00.190-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Git"/><category scheme="http://www.blogger.com/atom/ns#" term="Windows"/><title type='text'>Git: Unable to create &#39;.git/index.lock&#39;: File exists</title><content type='html'>&lt;p&gt;Ejecutas &lt;code&gt;git add&lt;/code&gt;, &lt;code&gt;git commit&lt;/code&gt;, &lt;code&gt;git checkout&lt;/code&gt; o &lt;code&gt;git merge&lt;/code&gt; y Git se detiene con este mensaje:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fatal: Unable to create &#39;C:/proyectos/mi-app/.git/index.lock&#39;: File exists.

Another git process seems to be running in this repository, or the lock file may be stale&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En versiones anteriores de Git el texto es más largo, pero dice lo mismo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fatal: Unable to create &#39;/home/usuario/mi-app/.git/index.lock&#39;: File exists.

Another git process seems to be running in this repository, e.g.
an editor opened by &#39;git commit&#39;. Please make sure all processes
are terminated then try again. If it still fails, a git process
may have crashed in this repository earlier:
remove the file manually to continue.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica qué es ese archivo, cómo distinguir un bloqueo huérfano de uno legítimo y cómo eliminarlo con seguridad en Windows, Linux o WSL.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Antes de modificar un archivo importante del repositorio, como el índice (el área de preparación o &lt;em&gt;staging&lt;/em&gt;), Git crea junto a él un archivo con el mismo nombre y la extensión &lt;code&gt;.lock&lt;/code&gt;. Según la documentación técnica de Git, el archivo de bloqueo se crea con la bandera &lt;code&gt;O_CREAT|O_EXCL&lt;/code&gt;, es decir, la creación falla si ya existe. Git escribe en él el contenido nuevo y, al terminar, lo renombra al nombre original. Mientras tanto, quien solo lee sigue viendo el contenido anterior.&lt;/p&gt;
&lt;p&gt;La documentación también explica que Git elimina sus archivos de bloqueo al salir, incluso cuando termina con un error o por una señal que puede capturar. Si ves el error, hay dos explicaciones posibles:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Otro proceso de Git está trabajando de verdad.&lt;/strong&gt; Puede ser un IDE que consulta el estado en segundo plano, una interfaz gráfica de Git, un script o un comando tuyo en otra terminal. La documentación de &lt;code&gt;git status&lt;/code&gt; advierte que, por defecto, escribe el índice refrescado y que ese bloqueo puede hacer fallar a otros procesos simultáneos. También un comando lento puede mantener el bloqueo: en una prueba con Git 2.54 para Windows, un &lt;code&gt;git commit -a&lt;/code&gt; con un hook &lt;code&gt;pre-commit&lt;/code&gt; de unos segundos mantuvo &lt;code&gt;.git/index.lock&lt;/code&gt; mientras el hook corría, y un &lt;code&gt;git add&lt;/code&gt; lanzado en ese lapso falló con este mismo error. Al terminar el hook, el archivo desapareció.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El bloqueo quedó huérfano.&lt;/strong&gt; Si un proceso de Git se interrumpe de forma abrupta (cierre forzado, apagado del equipo, fallo del sistema), no llega a limpiar su archivo. El propio mensaje lo menciona: &lt;em&gt;a git process may have crashed in this repository earlier&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Observa que no todos los comandos fallan: con un &lt;code&gt;index.lock&lt;/code&gt; presente, &lt;code&gt;git status&lt;/code&gt; siguió funcionando en la prueba, porque la escritura del índice que hace es opcional.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Identifica el archivo exacto.&lt;/strong&gt; La ruta aparece en el mensaje. En un repositorio normal es &lt;code&gt;.git/index.lock&lt;/code&gt;; en un worktree adicional (&lt;code&gt;git worktree add&lt;/code&gt;) está en una carpeta propia dentro de &lt;code&gt;.git/worktrees/&lt;/code&gt;. Para ver la ruta correcta desde cualquier carpeta del repositorio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git rev-parse --git-path index.lock&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba si hay realmente un proceso de Git en ejecución.&lt;/strong&gt; En Linux, macOS o WSL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pgrep -a git&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En PowerShell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Get-Process git -ErrorAction SilentlyContinue&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;(En el Administrador de tareas también puedes buscar &lt;code&gt;git.exe&lt;/code&gt;.) Revisa además si tu editor o una herramienta gráfica de Git está haciendo una operación. &lt;strong&gt;Si hay un proceso activo, espera a que termine&lt;/strong&gt; o ciérralo de forma normal, y vuelve a intentarlo. No borres el archivo mientras alguien lo está usando.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si no hay ningún proceso de Git, borra el archivo.&lt;/strong&gt; En Bash:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rm -f .git/index.lock&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En PowerShell, desde la raíz del repositorio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Remove-Item .git\index.lock&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El archivo de bloqueo solo contiene la actualización a medio escribir, no tus datos: el índice original sigue intacto hasta que Git hace el renombrado final. Al borrar un bloqueo huérfano solo pierdes una actualización que nunca se completó.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Repite el comando que falló.&lt;/strong&gt; Por ejemplo, &lt;code&gt;git add .&lt;/code&gt;, &lt;code&gt;git commit&lt;/code&gt; o &lt;code&gt;git merge&lt;/code&gt;. Si el error vuelve a aparecer enseguida, hay un proceso que lo recrea: vuelve al paso 2 y revisa herramientas en segundo plano.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git status
git fsck --no-dangling
git log --oneline -3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;git status&lt;/code&gt; debe mostrar el estado sin errores. &lt;code&gt;git fsck&lt;/code&gt; verifica la conectividad y la validez de los objetos de la base de datos del repositorio; con &lt;code&gt;--no-dangling&lt;/code&gt; omite la lista de objetos sin referencia. En la prueba no mostró salida y terminó con código de salida 0. &lt;code&gt;git log&lt;/code&gt; confirma que &lt;code&gt;HEAD&lt;/code&gt; apunta al último commit esperado. En PowerShell, &lt;code&gt;Test-Path .git\index.lock&lt;/code&gt; debe devolver &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;En la reproducción local (un bloqueo vacío creado a mano, sin ningún proceso de Git activo), tras borrar el archivo el &lt;code&gt;git add&lt;/code&gt; y el &lt;code&gt;git commit&lt;/code&gt; pendientes se completaron y &lt;code&gt;git fsck --no-dangling&lt;/code&gt; terminó con código 0.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Otros archivos de bloqueo: &lt;code&gt;HEAD.lock&lt;/code&gt;, referencias y configuración&lt;/h3&gt;
&lt;p&gt;El mismo mecanismo protege otros archivos. Con un bloqueo presente, estos fueron los mensajes en la prueba:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fatal: cannot lock ref &#39;HEAD&#39;: Unable to create &#39;.../.git/HEAD.lock&#39;: File exists.
fatal: cannot lock ref &#39;HEAD&#39;: Unable to create &#39;.../.git/refs/heads/master.lock&#39;: File exists.
error: could not lock config file .git/config: File exists&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La solución es la misma: confirma que no hay procesos de Git activos y borra únicamente el archivo &lt;code&gt;.lock&lt;/code&gt; que menciona el mensaje.&lt;/p&gt;
&lt;h3&gt;Un mensaje con el número de proceso&lt;/h3&gt;
&lt;p&gt;En el código fuente más reciente de Git (rama principal), el mensaje puede incluir textos como &lt;em&gt;Lock may be held by process&lt;/em&gt; seguido de un identificador de proceso. Si lo ves, comprueba ese identificador antes de borrar nada.&lt;/p&gt;
&lt;h3&gt;Herramientas que consultan el estado en segundo plano&lt;/h3&gt;
&lt;p&gt;Si tu editor o un script ejecuta &lt;code&gt;git status&lt;/code&gt; continuamente, puede provocar este error en tus comandos. La documentación recomienda que los procesos en segundo plano usen:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git --no-optional-locks status&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Es equivalente a definir la variable de entorno &lt;code&gt;GIT_OPTIONAL_LOCKS=0&lt;/code&gt;: Git completa la operación sin las tareas opcionales que requieren un bloqueo, como refrescar el índice.&lt;/p&gt;
&lt;h3&gt;Hooks lentos&lt;/h3&gt;
&lt;p&gt;Si el bloqueo aparece siempre durante un &lt;code&gt;git commit&lt;/code&gt; con hooks que ejecutan compilaciones o pruebas, el comando de Git es el que está en marcha: espera a que termine el hook antes de lanzar otro comando en ese repositorio.&lt;/p&gt;
&lt;h3&gt;Varios worktrees&lt;/h3&gt;
&lt;p&gt;Cada worktree tiene su propio &lt;code&gt;index.lock&lt;/code&gt;. En la prueba, un &lt;code&gt;index.lock&lt;/code&gt; en el repositorio principal no impidió un &lt;code&gt;git add&lt;/code&gt; en un worktree adicional.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;index.lock&lt;/code&gt; es el archivo temporal con el que Git protege la actualización del índice; si ya existe, Git se niega a continuar.&lt;/li&gt;
  &lt;li&gt;Puede ser un proceso de Git legítimo (IDE, script, hook lento) o un bloqueo huérfano de un proceso interrumpido.&lt;/li&gt;
  &lt;li&gt;Comprueba primero los procesos (&lt;code&gt;pgrep -a git&lt;/code&gt; o &lt;code&gt;Get-Process git&lt;/code&gt;); si hay uno activo, espera.&lt;/li&gt;
  &lt;li&gt;Si no hay ninguno, borra solo ese archivo (&lt;code&gt;rm -f .git/index.lock&lt;/code&gt; o &lt;code&gt;Remove-Item .git\index.lock&lt;/code&gt;) y repite el comando.&lt;/li&gt;
  &lt;li&gt;Para herramientas en segundo plano, usa &lt;code&gt;git --no-optional-locks status&lt;/code&gt;; verifica con &lt;code&gt;git status&lt;/code&gt; y &lt;code&gt;git fsck --no-dangling&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/api-lockfile&quot;&gt;Git: API de archivos de bloqueo (&lt;code&gt;api-lockfile&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-status&quot;&gt;Git: &lt;code&gt;git status&lt;/code&gt;, sección Background Refresh&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git&quot;&gt;Git: &lt;code&gt;git&lt;/code&gt;, opción &lt;code&gt;--no-optional-locks&lt;/code&gt; y variable &lt;code&gt;GIT_OPTIONAL_LOCKS&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/8249976444756265311/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/git-unable-to-create-gitindexlock-file.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/8249976444756265311'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/8249976444756265311'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/git-unable-to-create-gitindexlock-file.html' title='Git: Unable to create &#39;.git/index.lock&#39;: File exists'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-4906149111992342082</id><published>2026-10-31T15:00:00.000-05:00</published><updated>2026-10-31T15:00:00.118-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Prisma"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>Prisma update con undefined no borra el campo: usa null</title><content type='html'>&lt;p&gt;Editas un registro, borras el contenido de un campo opcional (o quitas una relación opcional), pulsas &quot;Guardar&quot; y la interfaz confirma que todo salió bien. Al recargar, el valor anterior sigue ahí. No hay ningún error en el servidor. Y si intentas enviar &lt;code&gt;null&lt;/code&gt; para forzar el borrado, el esquema de validación lo rechaza:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Invalid input: expected number, received null&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(En Zod 3 el texto es &lt;code&gt;Expected number, received null&lt;/code&gt;.) Este artículo explica por qué Prisma ignora el cambio, cómo corregir el esquema de Zod para actualizaciones parciales (PATCH) y qué debe enviar el formulario para que &quot;vaciar&quot; un campo funcione.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La documentación de Prisma Client fija dos reglas: &lt;em&gt;null is a value&lt;/em&gt; (null es un valor) y &lt;em&gt;undefined means do nothing&lt;/em&gt; (undefined significa no hacer nada). En un &lt;code&gt;update&lt;/code&gt;, una propiedad con &lt;code&gt;undefined&lt;/code&gt; se trata como si no estuviera, y solo &lt;code&gt;null&lt;/code&gt; escribe &lt;code&gt;NULL&lt;/code&gt; en la base de datos. Con este modelo de ejemplo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model Categoria {
  id        Int        @id @default(autoincrement())
  nombre    String
  productos Producto[]
}

model Producto {
  id          Int        @id @default(autoincrement())
  nombre      String
  descripcion String?
  categoriaId Int?
  categoria   Categoria? @relation(fields: [categoriaId], references: [id])
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Partiendo de un producto con descripción y categoría, esto es lo que dejó cada llamada (Prisma 6.19, SQLite):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;update({ data: { descripcion: undefined, categoriaId: undefined } })  // sin cambios
update({ data: { descripcion: null } })                                // descripcion = NULL
update({ data: { categoriaId: null } })                                // categoriaId = NULL&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ahora, ¿cómo llega &lt;code&gt;undefined&lt;/code&gt; al &lt;code&gt;update&lt;/code&gt; si el usuario solo vació el campo? Hay tres eslabones que fallan a la vez:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Una transformación de Zod convierte &lt;code&gt;&quot;&quot;&lt;/code&gt; en &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; Es un patrón muy común para &quot;limpiar&quot; formularios:
&lt;pre&gt;&lt;code&gt;const textoOpcional = z.string().optional().transform((v) =&amp;gt; v?.trim() || undefined);
// parse({ descripcion: &#39;&#39; })  -&amp;gt;  { descripcion: undefined }&lt;/code&gt;&lt;/pre&gt;
  Un campo vacío y un campo ausente quedan igual, y Prisma interpreta ambos como &quot;no tocar&quot;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El esquema de actualización no admite &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; Con &lt;code&gt;z.number().int().optional()&lt;/code&gt;, enviar &lt;code&gt;null&lt;/code&gt; da el error de arriba. El formulario no tenía forma legal de pedir &quot;ninguno&quot;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El selector (combobox) no ofrecía &quot;sin valor&quot;.&lt;/strong&gt; Al quitar la selección enviaba &lt;code&gt;undefined&lt;/code&gt;, y &lt;code&gt;JSON.stringify&lt;/code&gt; descarta las propiedades con &lt;code&gt;undefined&lt;/code&gt;:
&lt;pre&gt;&lt;code&gt;JSON.stringify({ a: undefined, b: null })  // &#39;{&quot;b&quot;:null}&#39;&lt;/code&gt;&lt;/pre&gt;
  La propiedad ni siquiera viaja en la petición.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;En el esquema, convierte el texto vacío en &lt;code&gt;null&lt;/code&gt; y admite &lt;code&gt;null&lt;/code&gt; y ausencia.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { z } from &#39;zod&#39;;

const textoLimpiable = z
  .string()
  .trim()
  .transform((v) =&amp;gt; (v === &#39;&#39; ? null : v))
  .nullable()
  .optional();

export const actualizarProductoSchema = z.object({
  nombre: z.string().trim().min(1).optional(),   // obligatorio en BD: nunca null
  descripcion: textoLimpiable,                   // se puede vaciar
  categoriaId: z.number().int().positive().nullable().optional(),
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Así se conservan los tres estados: clave ausente (no tocar), &lt;code&gt;null&lt;/code&gt; (borrar) y valor (guardar). En la prueba, &lt;code&gt;parse({})&lt;/code&gt; devolvió &lt;code&gt;{}&lt;/code&gt; sin ninguna clave, &lt;code&gt;parse({ descripcion: &#39;&#39; })&lt;/code&gt; y &lt;code&gt;parse({ descripcion: &#39;   &#39; })&lt;/code&gt; devolvieron &lt;code&gt;{ descripcion: null }&lt;/code&gt;, y &lt;code&gt;parse({ categoriaId: null })&lt;/code&gt; devolvió &lt;code&gt;{ categoriaId: null }&lt;/code&gt;, tanto con Zod 4.6 como con Zod 3.25.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pasa el resultado a Prisma tal cual.&lt;/strong&gt; No &quot;limpies&quot; los &lt;code&gt;null&lt;/code&gt; antes del &lt;code&gt;update&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function actualizarProducto(id: number, body: unknown) {
  const data = actualizarProductoSchema.parse(body);
  return prisma.producto.update({ where: { id }, data });
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Las claves ausentes no se tocan y las que valen &lt;code&gt;null&lt;/code&gt; se borran. Evita patrones como &lt;code&gt;valor != null ? valor : undefined&lt;/code&gt;: la propia documentación de Prisma lo muestra como forma de ignorar un &lt;code&gt;null&lt;/code&gt;, que es justo lo contrario de lo que necesitas aquí.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;En la interfaz, envía &lt;code&gt;null&lt;/code&gt; de forma explícita&lt;/strong&gt; y ofrece una opción para quitar el valor:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;select
  value={form.categoriaId ?? &#39;&#39;}
  onChange={(e) =&amp;gt;
    setForm({ ...form, categoriaId: e.target.value === &#39;&#39; ? null : Number(e.target.value) })
  }
&amp;gt;
  &amp;lt;option value=&quot;&quot;&amp;gt;Quitar categoría&amp;lt;/option&amp;gt;
  {categorias.map((c) =&amp;gt; (
    &amp;lt;option key={c.id} value={c.id}&amp;gt;{c.nombre}&amp;lt;/option&amp;gt;
  ))}
&amp;lt;/select&amp;gt;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Para valores de texto basta con &lt;code&gt;valor || null&lt;/code&gt;. Con identificadores numéricos usa la comparación con &lt;code&gt;&#39;&#39;&lt;/code&gt; como arriba, porque &lt;code&gt;0&lt;/code&gt; también es falso.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Para relaciones opcionales, elige un estilo y no los mezcles.&lt;/strong&gt; Con la clave foránea escalar:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prisma.producto.update({ where: { id }, data: { categoriaId: null } });&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;O con la relación:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prisma.producto.update({ where: { id }, data: { categoria: { disconnect: true } } });&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Ambas dejaron &lt;code&gt;categoriaId&lt;/code&gt; en &lt;code&gt;NULL&lt;/code&gt; en la prueba. La documentación indica que &lt;code&gt;disconnect&lt;/code&gt; solo está disponible si la relación es opcional. Si pones &lt;code&gt;categoriaId&lt;/code&gt; y &lt;code&gt;categoria&lt;/code&gt; en el mismo &lt;code&gt;data&lt;/code&gt;, Prisma lanza un &lt;code&gt;PrismaClientValidationError&lt;/code&gt; con un mensaje como &lt;code&gt;Unknown argument `categoriaId`. Did you mean `categoria`?&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Primero, prueba el esquema de forma aislada, sin base de datos:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;expect(actualizarProductoSchema.parse({})).toEqual({});
expect(&#39;descripcion&#39; in actualizarProductoSchema.parse({})).toBe(false);
expect(actualizarProductoSchema.parse({ descripcion: &#39;&#39; })).toEqual({ descripcion: null });
expect(actualizarProductoSchema.parse({ categoriaId: null })).toEqual({ categoriaId: null });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Después, comprueba el recorrido completo contra una base de datos de desarrollo: edita un registro, vacía la descripción y quita la categoría, guarda, recarga la página y consulta la fila (con Prisma Studio o una consulta SQL). Las dos columnas deben estar en &lt;code&gt;NULL&lt;/code&gt;. En el recorrido de prueba con el cliente de Prisma, un PATCH con solo &lt;code&gt;nombre&lt;/code&gt; dejó la descripción y la categoría intactas; uno con &lt;code&gt;descripcion: &#39;&#39;&lt;/code&gt; dejó la descripción en &lt;code&gt;NULL&lt;/code&gt;, y uno con &lt;code&gt;categoriaId: null&lt;/code&gt; dejó la categoría en &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Campos numéricos con &lt;code&gt;z.coerce.number()&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Un campo numérico vacío no se comporta como el texto: &lt;code&gt;z.coerce.number().safeParse(&#39;&#39;)&lt;/code&gt; devolvió éxito con el valor &lt;code&gt;0&lt;/code&gt;. Vaciar el campo guardaría un cero. Convierte la cadena vacía antes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const numeroLimpiable = z.preprocess(
  (v) =&amp;gt; (v === &#39;&#39; ? null : v),
  z.coerce.number().nullable().optional(),
);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Campos obligatorios&lt;/h3&gt;
&lt;p&gt;No agregues &lt;code&gt;.nullable()&lt;/code&gt; a las columnas que en la base de datos son obligatorias: el esquema debe rechazar el borrado antes de llegar a Prisma, con un error de validación claro.&lt;/p&gt;
&lt;h3&gt;Columnas &lt;code&gt;Json&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;En campos &lt;code&gt;Json&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt; es ambiguo (¿el valor JSON &lt;code&gt;null&lt;/code&gt; o &lt;code&gt;NULL&lt;/code&gt; de la base de datos?). Prisma ofrece &lt;code&gt;Prisma.JsonNull&lt;/code&gt; y &lt;code&gt;Prisma.DbNull&lt;/code&gt; para distinguirlos; revisa la documentación de campos JSON antes de usar &lt;code&gt;null&lt;/code&gt; directamente.&lt;/p&gt;
&lt;h3&gt;Reutiliza la definición en creación y actualización&lt;/h3&gt;
&lt;p&gt;Si el esquema de creación y el de actualización definen el mismo campo de forma distinta (uno con &lt;code&gt;.optional()&lt;/code&gt; y otro con &lt;code&gt;.nullable().optional()&lt;/code&gt;), es fácil que vuelva a aparecer la diferencia entre vaciar y no tocar. Define los campos limpiables una sola vez (por ejemplo, &lt;code&gt;textoLimpiable&lt;/code&gt;) y reutilízalos en ambos esquemas.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;En Prisma, &lt;code&gt;undefined&lt;/code&gt; significa &quot;no tocar el campo&quot; y &lt;code&gt;null&lt;/code&gt; significa &quot;escribir NULL&quot;.&lt;/li&gt;
  &lt;li&gt;Una transformación como &lt;code&gt;v?.trim() || undefined&lt;/code&gt; convierte &quot;vaciar&quot; en &quot;no hacer nada&quot; sin dar error.&lt;/li&gt;
  &lt;li&gt;Para campos limpiables usa &lt;code&gt;.nullable().optional()&lt;/code&gt; y transforma &lt;code&gt;&quot;&quot;&lt;/code&gt; en &lt;code&gt;null&lt;/code&gt;; deja que las claves ausentes sigan ausentes.&lt;/li&gt;
  &lt;li&gt;El formulario debe enviar &lt;code&gt;null&lt;/code&gt; de forma explícita (&lt;code&gt;JSON.stringify&lt;/code&gt; elimina &lt;code&gt;undefined&lt;/code&gt;) y ofrecer una opción &quot;Quitar&quot;.&lt;/li&gt;
  &lt;li&gt;Para relaciones opcionales usa &lt;code&gt;categoriaId: null&lt;/code&gt; o &lt;code&gt;categoria: { disconnect: true }&lt;/code&gt;, nunca ambos en la misma operación.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined&quot;&gt;Prisma: &lt;code&gt;null&lt;/code&gt; y &lt;code&gt;undefined&lt;/code&gt; en Prisma Client&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/reference/prisma-client-reference&quot;&gt;Prisma: referencia de Prisma Client (consultas anidadas, &lt;code&gt;disconnect&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zod.dev/api&quot;&gt;Zod: referencia de la API (&lt;code&gt;nullable&lt;/code&gt;, &lt;code&gt;optional&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/4906149111992342082/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/prisma-update-con-undefined-no-borra-el.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4906149111992342082'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/4906149111992342082'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/prisma-update-con-undefined-no-borra-el.html' title='Prisma update con undefined no borra el campo: usa null'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-558057151212690573</id><published>2026-10-31T09:00:00.000-05:00</published><updated>2026-10-31T09:00:00.119-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="CSS"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Next.js"/><title type='text'>Flexbox: la tabla ancha rompe el layout (falta min-w-0)</title><content type='html'>&lt;p&gt;Tienes un panel con barra lateral y un contenido principal, todo con Flexbox. Pones dentro una tabla ancha y, en lugar de aparecer una barra de desplazamiento horizontal en la tabla, ocurre esto:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Toda la página se ensancha y aparece un scroll horizontal en el navegador, no en la tabla.&lt;/li&gt;
  &lt;li&gt;Aparece además un segundo scroll vertical fuera del contenido: al bajar se mueven la barra lateral y la cabecera, y debajo queda una zona en blanco.&lt;/li&gt;
  &lt;li&gt;En un modal con altura limitada, las filas de una lista se aplastan hasta quedar de unos pocos píxeles.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Esta es una estructura típica que produce el problema:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;flex h-screen&quot;&amp;gt;
  &amp;lt;aside class=&quot;hidden w-56 shrink-0 bg-slate-900 p-4 text-white md:block&quot;&amp;gt;Menú&amp;lt;/aside&amp;gt;
  &amp;lt;div class=&quot;flex flex-1 flex-col&quot;&amp;gt;
    &amp;lt;header class=&quot;h-14 shrink-0 border-b px-4&quot;&amp;gt;Cabecera&amp;lt;/header&amp;gt;
    &amp;lt;main class=&quot;flex-1 p-4&quot;&amp;gt;
      &amp;lt;div class=&quot;overflow-y-auto&quot;&amp;gt;
        &amp;lt;div class=&quot;overflow-x-auto&quot;&amp;gt;
          &amp;lt;table class=&quot;w-[2600px] table-fixed text-sm&quot;&amp;gt;...&amp;lt;/table&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/main&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Al medirla en el navegador con una ventana de 1366 &amp;times; 768 px, estos fueron los valores:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;columna de contenido:   2632 px de ancho   (la tabla mide 2600 px)
página:                 scrollWidth 2856, clientWidth 1351
contenedor de la tabla: scrollWidth 2600 = clientWidth 2600  -&amp;gt; nunca hay barra&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Los tres síntomas tienen una raíz común en cómo Flexbox calcula el tamaño mínimo de sus elementos.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;Los elementos hijos de un contenedor flex tienen por defecto &lt;code&gt;min-width: auto&lt;/code&gt; y &lt;code&gt;min-height: auto&lt;/code&gt;. Según MDN, para un elemento flex ese mínimo automático es el tamaño mínimo de su contenido (&lt;code&gt;min-content&lt;/code&gt;), salvo que el elemento sea un contenedor de scroll: en ese caso el mínimo automático es &lt;code&gt;0&lt;/code&gt;. La especificación (CSS Flexible Box Layout, sección &quot;Automatic Minimum Size of Flex Items&quot;) lo expresa igual: el mínimo automático es el tamaño mínimo basado en el contenido y, para los contenedores de scroll, cero.&lt;/p&gt;
&lt;p&gt;Esto explica los tres síntomas:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;La tabla ensancha todo.&lt;/strong&gt; La columna de contenido (&lt;code&gt;flex-1&lt;/code&gt;) no es un contenedor de scroll, así que no puede ser más estrecha que el contenido más ancho que lleve dentro: la tabla de 2600 px. El contenedor con &lt;code&gt;overflow-x: auto&lt;/code&gt; que envuelve la tabla recibe un ancho de 2600 px, no desborda y por eso no muestra barra. Es la columna la que desborda a la página.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;El segundo scroll vertical.&lt;/strong&gt; Si el &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; es un elemento flex en columna y el scroll lo tiene un elemento interior (o ninguno), su altura mínima automática es la del contenido: &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; crece y empuja todo. Con un contenedor de altura &lt;code&gt;100vh&lt;/code&gt;, el desbordamiento lo gestiona la página, y la barra lateral (que mide lo mismo que el contenedor) se desplaza con ella.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Las filas aplastadas.&lt;/strong&gt; &lt;code&gt;flex-shrink&lt;/code&gt; vale &lt;code&gt;1&lt;/code&gt; por defecto (MDN), así que los elementos se encogen para no desbordar el contenedor. Normalmente &lt;code&gt;min-height: auto&lt;/code&gt; impide que bajen del tamaño de su contenido, pero una fila con &lt;code&gt;overflow: hidden&lt;/code&gt; (por ejemplo, para recortar texto con &lt;code&gt;truncate&lt;/code&gt;) es un contenedor de scroll, su mínimo automático es &lt;code&gt;0&lt;/code&gt; y se encoge hasta quedarse en su relleno (&lt;em&gt;padding&lt;/em&gt;). Con &lt;code&gt;py-2&lt;/code&gt; eso son 16 px.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Estructura del layout con Tailwind.&lt;/strong&gt; Este es el esqueleto completo (puedes ponerlo en el &lt;code&gt;layout.tsx&lt;/code&gt; de tu panel en Next.js):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;flex h-dvh overflow-hidden&quot;&amp;gt;
  &amp;lt;aside class=&quot;hidden w-56 shrink-0 bg-slate-900 p-4 text-white md:block&quot;&amp;gt;Menú&amp;lt;/aside&amp;gt;

  &amp;lt;div class=&quot;flex min-w-0 flex-1 flex-col&quot;&amp;gt;
    &amp;lt;header class=&quot;h-14 shrink-0 border-b px-4&quot;&amp;gt;Cabecera&amp;lt;/header&amp;gt;

    &amp;lt;main class=&quot;min-h-0 flex-1 overflow-y-auto p-4&quot;&amp;gt;
      &amp;lt;div class=&quot;overflow-x-auto&quot;&amp;gt;
        &amp;lt;table class=&quot;w-[2600px] table-fixed text-sm&quot;&amp;gt;
          &amp;lt;!-- filas --&amp;gt;
        &amp;lt;/table&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/main&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;code&gt;h-dvh overflow-hidden&lt;/code&gt; en el contenedor raíz: la página ocupa exactamente la ventana y nunca tiene scroll propio. &lt;code&gt;h-dvh&lt;/code&gt; equivale a &lt;code&gt;height: 100dvh&lt;/code&gt; (Tailwind lo documenta como la altura del viewport que cambia cuando se expande o se contrae la interfaz del navegador); si tu versión no lo incluye, usa &lt;code&gt;h-screen&lt;/code&gt;.&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;min-w-0&lt;/code&gt; en el hijo flex que contiene la tabla: devuelve el mínimo a &lt;code&gt;0&lt;/code&gt; y deja que la columna se ajuste al espacio disponible.&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;min-h-0 flex-1 overflow-y-auto&lt;/code&gt; en &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;: es el único elemento con scroll vertical.&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;overflow-x-auto&lt;/code&gt; en el contenedor directo de la tabla: es el único elemento con scroll horizontal. Un contenedor de scroll por eje y por tabla; no repitas &lt;code&gt;overflow&lt;/code&gt; en los ancestros.&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;shrink-0&lt;/code&gt; en la barra lateral y la cabecera, para que no se encojan.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;El mismo layout en CSS puro:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.shell   { display: flex; height: 100dvh; overflow: hidden; }
.sidebar { width: 14rem; flex-shrink: 0; }
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.header  { flex-shrink: 0; height: 3.5rem; }
.main    { flex: 1; min-height: 0; overflow-y: auto; padding: 1rem; }
.table-scroll { overflow-x: auto; }
.table-scroll table { width: 2600px; }&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En Tailwind 4.3, &lt;code&gt;min-w-0&lt;/code&gt; genera &lt;code&gt;min-width: 0&lt;/code&gt;, &lt;code&gt;min-h-0&lt;/code&gt; genera &lt;code&gt;min-height: 0&lt;/code&gt;, &lt;code&gt;shrink-0&lt;/code&gt; genera &lt;code&gt;flex-shrink: 0&lt;/code&gt; y &lt;code&gt;h-dvh&lt;/code&gt; genera &lt;code&gt;height: 100dvh&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Listas con scroll dentro de un modal o un panel de altura limitada:&lt;/strong&gt; evita que las filas se encojan.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;flex max-h-72 w-80 flex-col rounded border bg-white&quot;&amp;gt;
  &amp;lt;div class=&quot;shrink-0 border-b p-3&quot;&amp;gt;Buscar&amp;lt;/div&amp;gt;
  &amp;lt;ul class=&quot;flex flex-col overflow-y-auto&quot;&amp;gt;
    &amp;lt;li class=&quot;shrink-0 overflow-hidden px-3 py-2&quot;&amp;gt;Resultado 1&amp;lt;/li&amp;gt;
    &amp;lt;!-- más filas --&amp;gt;
  &amp;lt;/ul&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En CSS: &lt;code&gt;li { flex-shrink: 0; }&lt;/code&gt;. Así la lista conserva la altura de cada fila y desborda, y el scroll aparece en el &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;En la consola del navegador, con la vista que falla abierta:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const s = document.scrollingElement;
({ paginaScrollX: s.scrollWidth &amp;gt; s.clientWidth, paginaScrollY: s.scrollHeight &amp;gt; s.clientHeight })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ambos valores deben ser &lt;code&gt;false&lt;/code&gt;. Después comprueba que el único scroll vertical está en &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; (&lt;code&gt;main.scrollHeight &amp;gt; main.clientHeight&lt;/code&gt;) y que el contenedor de la tabla desborda por dentro (&lt;code&gt;wrapper.scrollWidth &amp;gt; wrapper.clientWidth&lt;/code&gt;). Repite con el ancho de un móvil.&lt;/p&gt;
&lt;p&gt;En la prueba, con ventanas de 1366 &amp;times; 768 y de 375 &amp;times; 812 px (Chromium), la versión que falla tenía scroll de página en ambos ejes y la columna medía 2632 px. Con el arreglo, la página no tenía scroll en ningún eje, &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; era el único con scroll vertical y la tabla de 2600 px se desplazaba dentro de su contenedor (1095 px visibles en escritorio, 328 px en móvil). En el modal, las filas pasaron de 16 px a su altura natural (entre 40 y 64 px, según si el texto ocupaba una o dos líneas).&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;overflow-hidden&lt;/code&gt; en el contenedor raíz sin &lt;code&gt;min-w-0&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Si pones &lt;code&gt;overflow-hidden&lt;/code&gt; en el contenedor raíz pero olvidas &lt;code&gt;min-w-0&lt;/code&gt;, el scroll de página desaparece y el problema queda oculto: en la prueba la columna seguía midiendo 2647 px, el contenedor de la tabla no mostraba barra y la tabla quedaba recortada por la ventana. Si ves una tabla cortada sin forma de desplazarla, revisa &lt;code&gt;min-w-0&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;¿Hace falta &lt;code&gt;min-h-0&lt;/code&gt; si ya hay &lt;code&gt;overflow-y-auto&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;Si &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; es el propio contenedor de scroll, no: su mínimo automático ya es &lt;code&gt;0&lt;/code&gt; (en la prueba funcionó igual sin &lt;code&gt;min-h-0&lt;/code&gt;). Sí hace falta cuando el scroll lo tiene un elemento interior, por ejemplo un &lt;code&gt;&amp;lt;div class=&quot;h-full overflow-y-auto&quot;&amp;gt;&lt;/code&gt; dentro de &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;: sin &lt;code&gt;min-h-0&lt;/code&gt;, &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; creció hasta el alto de su contenido (2304 px); con &lt;code&gt;min-h-0&lt;/code&gt; se quedó en el espacio disponible. Dejar ambas clases es inocuo y evita depender de ese detalle.&lt;/p&gt;
&lt;h3&gt;Lo mismo en CSS Grid&lt;/h3&gt;
&lt;p&gt;MDN indica que el mínimo automático también afecta a los elementos de una rejilla. En la prueba, con &lt;code&gt;grid-template-columns: 14rem 1fr&lt;/code&gt; la columna medía 2600 px y la página desbordaba; con &lt;code&gt;14rem minmax(0, 1fr)&lt;/code&gt; la columna medía 1142 px y la tabla se desplazaba dentro de su contenedor. En Tailwind: &lt;code&gt;grid-cols-[14rem_minmax(0,1fr)]&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Texto largo sin espacios en un elemento flex&lt;/h3&gt;
&lt;p&gt;Una URL o un identificador largo sin espacios puede ensanchar un elemento flex por el mismo motivo. En la prueba, con solo &lt;code&gt;overflow-wrap: break-word&lt;/code&gt; (clase &lt;code&gt;break-words&lt;/code&gt; en Tailwind) la columna siguió desbordando la página en un ancho de 375 px; al añadir &lt;code&gt;min-w-0&lt;/code&gt; a ese elemento el texto se partió y la página dejó de desbordar. Si prefieres cortar el texto con puntos suspensivos, usa &lt;code&gt;truncate&lt;/code&gt; junto con &lt;code&gt;min-w-0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Los elementos flex no se encogen por debajo del tamaño de su contenido (&lt;code&gt;min-width: auto&lt;/code&gt; y &lt;code&gt;min-height: auto&lt;/code&gt;), salvo que sean contenedores de scroll.&lt;/li&gt;
  &lt;li&gt;Una tabla ancha estira la columna de contenido; pon &lt;code&gt;min-w-0&lt;/code&gt; en el hijo flex que la contiene y &lt;code&gt;overflow-x-auto&lt;/code&gt; en su envoltorio directo.&lt;/li&gt;
  &lt;li&gt;Evita el segundo scroll vertical con un contenedor raíz &lt;code&gt;h-dvh overflow-hidden&lt;/code&gt; y &lt;code&gt;min-h-0 flex-1 overflow-y-auto&lt;/code&gt; en &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Las filas de una lista con scroll necesitan &lt;code&gt;shrink-0&lt;/code&gt; para no aplastarse cuando el contenedor tiene altura limitada.&lt;/li&gt;
  &lt;li&gt;Verifica con &lt;code&gt;scrollWidth&lt;/code&gt; y &lt;code&gt;clientWidth&lt;/code&gt; de la página: ambos ejes sin scroll de página y un solo contenedor de scroll por eje.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/min-width&quot;&gt;MDN: &lt;code&gt;min-width&lt;/code&gt; (valor &lt;code&gt;auto&lt;/code&gt; en elementos flex y de rejilla)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/flex-shrink&quot;&gt;MDN: &lt;code&gt;flex-shrink&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.w3.org/TR/css-flexbox-1/&quot;&gt;W3C: CSS Flexible Box Layout Module Level 1, sección 4.5 (tamaño mínimo automático)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/558057151212690573/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/flexbox-la-tabla-ancha-rompe-el-layout.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/558057151212690573'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/558057151212690573'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/flexbox-la-tabla-ancha-rompe-el-layout.html' title='Flexbox: la tabla ancha rompe el layout (falta min-w-0)'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7871748168994677819</id><published>2026-10-30T15:00:00.000-05:00</published><updated>2026-10-30T15:00:00.118-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Node.js"/><category scheme="http://www.blogger.com/atom/ns#" term="Testing"/><title type='text'>Tests pasan en local y fallan en CI: variables y CRLF</title><content type='html'>&lt;p&gt;Ejecutas la suite en tu equipo y todo está en verde. Subes el cambio, el flujo de CI se pone en rojo y el error no apunta a ninguna línea que hayas tocado:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; FAIL  src/pagos/route.test.ts &amp;gt; POST /api/pagos &amp;gt; crea un pago
AssertionError: expected 503 to be 201 // Object.is equality

 FAIL  src/importar.test.ts &amp;gt; fixture ventas.csv &amp;gt; tiene encabezado y 7 filas
AssertionError: expected [ Array(1) ] to have a length of 8 but got 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica dos causas muy comunes de &quot;pasa en local, falla en CI&quot; en proyectos Node.js con Vitest o Jest: variables de entorno leídas en el momento de importar un módulo y saltos de línea distintos entre Windows y Linux. Incluye cómo reproducir el fallo de CI en tu propia máquina antes de subir nada.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;h3&gt;Causa 1: &lt;code&gt;process.env&lt;/code&gt; se lee una sola vez, al importar&lt;/h3&gt;
&lt;p&gt;Mira este manejador de ruta (por ejemplo, un route handler de Next.js o cualquier módulo de Node.js):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/pagos/route.ts
const PAYMENT_KEY = process.env.PAYMENT_KEY; // se evalúa UNA vez, al importar el módulo

export async function POST(request: Request): Promise&amp;lt;Response&amp;gt; {
  if (!PAYMENT_KEY) {
    return Response.json({ error: &#39;Pagos no configurados&#39; }, { status: 503 });
  }
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un módulo ES se evalúa la primera vez que alguien lo importa, y los &lt;code&gt;import&lt;/code&gt; estáticos de un archivo de test se resuelven antes de que se ejecute el cuerpo de ese archivo. Por eso este intento de controlar la variable no sirve:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { beforeEach, describe, expect, it, vi } from &#39;vitest&#39;;
import { POST } from &#39;./route&#39;; // aquí se lee PAYMENT_KEY

beforeEach(() =&amp;gt; {
  vi.stubEnv(&#39;PAYMENT_KEY&#39;, &#39;clave-de-prueba&#39;); // llega tarde: la constante ya se fijó
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La constante quedó con el valor que tenía el proceso al arrancar. En tu equipo esa variable existe, porque la define tu terminal o un archivo &lt;code&gt;.env&lt;/code&gt; que carga tu configuración de pruebas. En CI no existe, y el manejador responde 503 en todos los tests que esperaban un pago correcto.&lt;/p&gt;
&lt;p&gt;La documentación de Vitest indica que, por defecto, solo carga automáticamente desde los archivos &lt;code&gt;.env&lt;/code&gt; las variables con prefijo &lt;code&gt;VITE_&lt;/code&gt;. Lo comprobé con Vitest 5.0.3: una variable &lt;code&gt;PAYMENT_KEY&lt;/code&gt; escrita en &lt;code&gt;.env&lt;/code&gt; no aparecía en &lt;code&gt;process.env&lt;/code&gt; dentro de los tests, y una &lt;code&gt;VITE_...&lt;/code&gt; sí. Si en tu proyecto la variable llega a los tests, es porque algo más la carga (tu shell, un &lt;code&gt;setup&lt;/code&gt; con &lt;code&gt;dotenv&lt;/code&gt;, &lt;code&gt;loadEnvConfig&lt;/code&gt; de &lt;code&gt;@next/env&lt;/code&gt; o la opción &lt;code&gt;test.env&lt;/code&gt;), y ese &quot;algo&quot; no existe igual en CI.&lt;/p&gt;
&lt;p&gt;El problema también funciona al revés: un test como &quot;responde 503 si falta la clave&quot; pasa en CI y falla en tu equipo, porque ahí la clave sí está definida. Depender del &lt;code&gt;.env&lt;/code&gt; de cada máquina hace que el resultado dependa de quién ejecute los tests.&lt;/p&gt;

&lt;h3&gt;Causa 2: el test solo entiende &lt;code&gt;\r\n&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Un test lee un CSV de ejemplo y lo parte en líneas con &lt;code&gt;split(&#39;\r\n&#39;)&lt;/code&gt;. El repositorio guarda el archivo con &lt;code&gt;\n&lt;/code&gt;. En Windows es habitual que &lt;code&gt;core.autocrlf&lt;/code&gt; esté en &lt;code&gt;true&lt;/code&gt; (compruébalo con &lt;code&gt;git config --show-origin --get core.autocrlf&lt;/code&gt;), y entonces Git convierte los saltos de línea al hacer checkout: el archivo llega con &lt;code&gt;\r\n&lt;/code&gt; y el test pasa. En el runner de Linux no hay conversión, el archivo conserva &lt;code&gt;\n&lt;/code&gt;, el &lt;code&gt;split&lt;/code&gt; no encuentra nada que cortar y devuelve un solo elemento con todo el contenido. Según cómo esté escrita la aserción, el test falla (como arriba) o, peor, &lt;strong&gt;pasa sin comprobar nada&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const rows = raw.split(&#39;\r\n&#39;).filter(Boolean).slice(1); // en Linux: []
rows.forEach((row) =&amp;gt; {
  expect(row.split(&#39;,&#39;)).toHaveLength(3); // nunca se ejecuta
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un bucle sobre una lista vacía no ejecuta ninguna aserción, y el test queda en verde.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reproduce CI en local.&lt;/strong&gt; Ejecuta la suite sin las variables que solo existen en tu equipo. En Git Bash o Linux:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;env -u PAYMENT_KEY npx vitest run&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En PowerShell (afecta solo a la sesión actual):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Remove-Item Env:PAYMENT_KEY -ErrorAction SilentlyContinue
npx vitest run&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si tu configuración carga un &lt;code&gt;.env&lt;/code&gt;, renómbralo temporalmente. Con este paso se reproduce el 503 sin esperar al pipeline.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Lee la variable cuando la necesites, no al importar.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const getPaymentKey = () =&amp;gt; process.env.PAYMENT_KEY;

export async function POST(request: Request): Promise&amp;lt;Response&amp;gt; {
  if (!getPaymentKey()) {
    return Response.json({ error: &#39;Pagos no configurados&#39; }, { status: 503 });
  }
  // ...
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Otra opción es recibir la configuración por parámetro (inyección de dependencias) y leer &lt;code&gt;process.env&lt;/code&gt; solo en un punto de entrada.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Define las variables dentro del test, no en el entorno de cada máquina.&lt;/strong&gt; &lt;code&gt;vi.stubEnv&lt;/code&gt; cambia el valor en &lt;code&gt;process.env&lt;/code&gt; y en &lt;code&gt;import.meta.env&lt;/code&gt;; &lt;code&gt;vi.unstubAllEnvs()&lt;/code&gt; restaura los valores originales:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { afterEach, beforeEach, describe, expect, it, vi } from &#39;vitest&#39;;
import { POST } from &#39;./route&#39;;

afterEach(() =&amp;gt; {
  vi.unstubAllEnvs();
});

describe(&#39;POST /api/pagos&#39;, () =&amp;gt; {
  beforeEach(() =&amp;gt; {
    vi.stubEnv(&#39;PAYMENT_KEY&#39;, &#39;clave-de-prueba&#39;);
  });

  it(&#39;crea un pago&#39;, async () =&amp;gt; {
    const res = await POST(new Request(&#39;http://localhost/api/pagos&#39;, {
      method: &#39;POST&#39;,
      body: JSON.stringify({ monto: 10 }),
    }));
    expect(res.status).toBe(201);
  });
});

describe(&#39;sin clave configurada&#39;, () =&amp;gt; {
  it(&#39;responde 503&#39;, async () =&amp;gt; {
    vi.stubEnv(&#39;PAYMENT_KEY&#39;, undefined); // el valor puede ser undefined
    // ...
  });
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si prefieres no escribir el &lt;code&gt;afterEach&lt;/code&gt; en cada archivo, Vitest tiene la opción &lt;code&gt;unstubEnvs: true&lt;/code&gt; en &lt;code&gt;test&lt;/code&gt;, que llama a &lt;code&gt;vi.unstubAllEnvs()&lt;/code&gt; antes de cada test (por defecto está en &lt;code&gt;false&lt;/code&gt;).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si no puedes cambiar el módulo&lt;/strong&gt;, reinicia el registro de módulos y vuelve a importarlo después de fijar la variable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let POST: (request: Request) =&amp;gt; Promise&amp;lt;Response&amp;gt;;

beforeEach(async () =&amp;gt; {
  vi.resetModules();
  vi.stubEnv(&#39;PAYMENT_KEY&#39;, &#39;clave-de-prueba&#39;);
  ({ POST } = await import(&#39;./route&#39;)); // se evalúa de nuevo, ya con la variable
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La documentación aclara que los imports de nivel superior no se pueden reevaluar, por eso hace falta el &lt;code&gt;import()&lt;/code&gt; dinámico. En Jest el equivalente es &lt;code&gt;jest.resetModules()&lt;/code&gt; (o &lt;code&gt;jest.isolateModules&lt;/code&gt;) más una asignación a &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Parte las líneas aceptando ambos separadores&lt;/strong&gt; y exige que el test compruebe algo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const lines = raw.split(/\r?\n/).filter(Boolean);

it(&#39;todas las filas tienen 3 columnas&#39;, () =&amp;gt; {
  expect.hasAssertions(); // falla si no se ejecutó ninguna aserción
  lines.slice(1).forEach((row) =&amp;gt; {
    expect(row.split(&#39;,&#39;)).toHaveLength(3);
  });
});&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fija los saltos de línea de los fixtures&lt;/strong&gt; con un archivo &lt;code&gt;.gitattributes&lt;/code&gt; en la raíz del repositorio:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*.csv text eol=lf&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Con &lt;code&gt;eol=lf&lt;/code&gt;, el archivo se queda con &lt;code&gt;\n&lt;/code&gt; en el directorio de trabajo, sea cual sea &lt;code&gt;core.autocrlf&lt;/code&gt;. Para corregir los archivos que ya estaban guardados con otro formato, ejecuta &lt;code&gt;git add --renormalize .&lt;/code&gt; y revisa el resultado con &lt;code&gt;git status&lt;/code&gt; antes de confirmar.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Ejecuta la suite en las cuatro combinaciones posibles: con y sin la variable, y con el fixture en LF y en CRLF.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PAYMENT_KEY=valor-local npx vitest run
env -u PAYMENT_KEY npx vitest run&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repite ambos comandos después de convertir el fixture de formato (por ejemplo, con un editor que permita elegir LF o CRLF para el archivo). En un proyecto de ejemplo con 10 tests, las cuatro ejecuciones terminaron con &quot;10 passed&quot;. Antes del arreglo, la ejecución sin variable fallaba en 6 de 8 tests, y con el fixture en LF el test del &lt;code&gt;split(&#39;\r\n&#39;)&lt;/code&gt; fallaba y otro pasaba sin ninguna aserción.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;La variable sí es necesaria en CI&lt;/h3&gt;
&lt;p&gt;Si un test de integración necesita un secreto real, defínelo en el flujo de trabajo con &lt;code&gt;env:&lt;/code&gt; a partir de &lt;code&gt;secrets&lt;/code&gt;. Ten en cuenta que, según la documentación de GitHub, los secretos no se pasan al runner cuando el flujo lo dispara un fork; ahí la variable no estará disponible. Para tests unitarios, usa valores de prueba en lugar de secretos.&lt;/p&gt;
&lt;h3&gt;Next.js y los archivos &lt;code&gt;.env&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;La documentación de Next.js indica que en el entorno &lt;code&gt;test&lt;/code&gt; no se carga &lt;code&gt;.env.local&lt;/code&gt;, para que los resultados no dependan de cada persona. Puedes versionar un &lt;code&gt;.env.test&lt;/code&gt; con valores falsos y cargarlo con &lt;code&gt;loadEnvConfig&lt;/code&gt; de &lt;code&gt;@next/env&lt;/code&gt; en tu configuración de pruebas.&lt;/p&gt;
&lt;h3&gt;El cache de módulos entre tests&lt;/h3&gt;
&lt;p&gt;Si usas &lt;code&gt;vi.resetModules()&lt;/code&gt; y el módulo mantiene estado (conexiones, singletons), cada &lt;code&gt;import()&lt;/code&gt; crea una instancia nueva. Es lo que buscas aquí, pero recuérdalo si compartes instancias entre tests.&lt;/p&gt;
&lt;h3&gt;Un fixture ya confirmado con CRLF&lt;/h3&gt;
&lt;p&gt;Cambiar &lt;code&gt;.gitattributes&lt;/code&gt; no reescribe por sí solo lo que está en el repositorio. Por eso existe &lt;code&gt;git add --renormalize&lt;/code&gt;: aplica de nuevo el filtro de normalización a todos los archivos versionados.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Una constante de módulo que lee &lt;code&gt;process.env&lt;/code&gt; se fija al importar; &lt;code&gt;vi.stubEnv&lt;/code&gt; en &lt;code&gt;beforeEach&lt;/code&gt; llega tarde.&lt;/li&gt;
  &lt;li&gt;Lee la variable dentro de una función o inyecta la configuración, y en los tests usa &lt;code&gt;vi.stubEnv&lt;/code&gt; con &lt;code&gt;vi.unstubAllEnvs()&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Reproduce CI ejecutando la suite sin las variables de tu equipo (&lt;code&gt;env -u VARIABLE&lt;/code&gt; o &lt;code&gt;Remove-Item Env:VARIABLE&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;Parte líneas con &lt;code&gt;/\r?\n/&lt;/code&gt;, usa &lt;code&gt;expect.hasAssertions()&lt;/code&gt; y fija los fixtures con &lt;code&gt;*.csv text eol=lf&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Prueba siempre las combinaciones con y sin variable, y con LF y CRLF.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://vitest.dev/api/vi.html&quot;&gt;Vitest: API &lt;code&gt;vi&lt;/code&gt; (&lt;code&gt;stubEnv&lt;/code&gt;, &lt;code&gt;unstubAllEnvs&lt;/code&gt;, &lt;code&gt;resetModules&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/gitattributes&quot;&gt;Git: &lt;code&gt;gitattributes&lt;/code&gt; (atributos &lt;code&gt;text&lt;/code&gt; y &lt;code&gt;eol&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/guides/environment-variables&quot;&gt;Next.js: variables de entorno (sección Test Environment Variables)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7871748168994677819/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/tests-pasan-en-local-y-fallan-en-ci.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7871748168994677819'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7871748168994677819'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/tests-pasan-en-local-y-fallan-en-ci.html' title='Tests pasan en local y fallan en CI: variables y CRLF'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-1422095846712956874</id><published>2026-10-30T09:00:00.000-05:00</published><updated>2026-10-30T09:00:00.172-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Bases de datos"/><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Prisma"/><title type='text'>Postgres: AT TIME ZONE no convierte un timestamp sin zona</title><content type='html'>&lt;p&gt;Guardas fechas en UTC en columnas &lt;code&gt;timestamp without time zone&lt;/code&gt; (es lo que genera Prisma por defecto) y quieres mostrarlas en la hora local de usuarios que están en UTC-5. Aplicas &lt;code&gt;AT TIME ZONE&lt;/code&gt; y el resultado parece incorrecto: con la sesión en UTC, el valor guardado &lt;code&gt;2026-10-11 15:00:00&lt;/code&gt; sale como una hora &lt;em&gt;posterior&lt;/em&gt;, y con la sesión en la zona local sale idéntico, como si no hubiera convertido nada:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT creado_en AT TIME ZONE &#39;America/Bogota&#39; FROM pedidos;

-- sesión en UTC:
2026-10-11 20:00:00+00
-- sesión en America/Bogota:
2026-10-11 15:00:00-05&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La hora local correcta sería &lt;code&gt;10:00&lt;/code&gt;. Un problema emparentado: las filas que insertó una migración con &lt;code&gt;now()&lt;/code&gt; aparecen 5 horas desfasadas en la aplicación, y los cálculos por días (ventanas de vigencia, informes por fecha) cambian de día cerca de la medianoche. Todo tiene el mismo origen.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;La documentación de PostgreSQL (funciones de fecha y hora) define dos comportamientos distintos para &lt;code&gt;AT TIME ZONE&lt;/code&gt;, según el tipo del valor:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;timestamp without time zone AT TIME ZONE zona&lt;/code&gt; devuelve un &lt;code&gt;timestamp with time zone&lt;/code&gt; y supone que el valor ya está expresado en esa zona.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;timestamp with time zone AT TIME ZONE zona&lt;/code&gt; devuelve un &lt;code&gt;timestamp without time zone&lt;/code&gt;: la hora que marcaría un reloj en esa zona.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Con una columna sin zona, &lt;code&gt;AT TIME ZONE &#39;America/Bogota&#39;&lt;/code&gt; no convierte desde UTC: declara que &lt;code&gt;15:00&lt;/code&gt; ya es hora de Bogotá, lo que equivale a 20:00 UTC. El resultado es un &lt;code&gt;timestamptz&lt;/code&gt;, y &lt;code&gt;psql&lt;/code&gt; lo muestra con la zona de la sesión (&lt;code&gt;TimeZone&lt;/code&gt;): &lt;code&gt;20:00+00&lt;/code&gt; en una sesión UTC y &lt;code&gt;15:00-05&lt;/code&gt; en una sesión de Bogotá. Es la dirección contraria a la que mucha gente espera.&lt;/p&gt;
&lt;p&gt;El desfase de &lt;code&gt;now()&lt;/code&gt; tiene la misma raíz. &lt;code&gt;now()&lt;/code&gt; devuelve un &lt;code&gt;timestamptz&lt;/code&gt;, y al guardarlo en una columna sin zona PostgreSQL aplica la regla general: las conversiones entre ambos tipos suponen que el valor sin zona está en la hora local de &lt;code&gt;TimeZone&lt;/code&gt;. Si la migración corre con la sesión en &lt;code&gt;America/Bogota&lt;/code&gt;, se guarda la hora de pared local. Una aplicación que interprete esa columna como UTC la lee 5 horas antes. PostgreSQL no guarda ninguna zona en esa columna, así que la convención es de la aplicación; Prisma, cuyo tipo &lt;code&gt;DateTime&lt;/code&gt; se mapea por defecto a &lt;code&gt;timestamp(3)&lt;/code&gt;, suele tratar esos valores como UTC, aunque su documentación no lo garantiza de forma explícita.&lt;/p&gt;
&lt;p&gt;Cerca de medianoche el desfase cambia el día: las 20:30 del día 11 en Bogotá son las 01:30 UTC del día 12. En la prueba, ese instante convertido a &lt;code&gt;timestamp&lt;/code&gt; con la sesión en hora local se guardó como &lt;code&gt;2026-10-11 20:30:00&lt;/code&gt; (fecha 11), mientras que en UTC es &lt;code&gt;2026-10-12 01:30:00&lt;/code&gt; (fecha 12). Cualquier cálculo por días o por fecha sale distinto según qué valor se use.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Para mostrar en hora local un valor guardado en UTC, usa dos veces &lt;code&gt;AT TIME ZONE&lt;/code&gt;.&lt;/strong&gt; La primera declara que el valor está en UTC (obtienes un &lt;code&gt;timestamptz&lt;/code&gt;) y la segunda lo expresa en la zona deseada (obtienes un &lt;code&gt;timestamp&lt;/code&gt; sin zona, que no depende de la sesión):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT creado_en AT TIME ZONE &#39;UTC&#39; AT TIME ZONE &#39;America/Bogota&#39; AS hora_local
FROM pedidos;

-- equivalente con la función timezone()
SELECT timezone(&#39;America/Bogota&#39;, timezone(&#39;UTC&#39;, creado_en)) AS hora_local
FROM pedidos;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Con el ejemplo anterior devuelve &lt;code&gt;2026-10-11 10:00:00&lt;/code&gt;, tanto con la sesión en UTC como en &lt;code&gt;America/Bogota&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Al guardar, convierte &lt;code&gt;now()&lt;/code&gt; a UTC de forma explícita&lt;/strong&gt; si la columna es &lt;code&gt;timestamp without time zone&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INSERT INTO sesiones (creado_en) VALUES (timezone(&#39;UTC&#39;, now()));
-- o, con la misma semántica:
INSERT INTO sesiones (creado_en) VALUES (now() AT TIME ZONE &#39;UTC&#39;);

-- valor por defecto de la columna
ALTER TABLE sesiones ALTER COLUMN creado_en SET DEFAULT timezone(&#39;UTC&#39;, now());&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Aplícalo también en las migraciones y scripts manuales que escriban estas columnas: no usan el código de tu aplicación.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;En columnas nuevas, prefiere &lt;code&gt;timestamptz&lt;/code&gt;.&lt;/strong&gt; PostgreSQL guarda el instante normalizado a UTC y lo convierte a la zona de la sesión al mostrarlo, así que no hay una convención implícita que se pueda romper. En Prisma se declara con el atributo nativo &lt;code&gt;&amp;#64;db.Timestamptz(3)&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model Pedido {
  id        Int      @id @default(autoincrement())
  creadoEn  DateTime @default(now()) &amp;#64;db.Timestamptz(3)
}&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Para convertir una columna existente, indica &lt;em&gt;siempre&lt;/em&gt; cómo interpretar los valores antiguos:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER TABLE pedidos
  ALTER COLUMN creado_en TYPE timestamptz
  USING creado_en AT TIME ZONE &#39;UTC&#39;;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Sin la cláusula &lt;code&gt;USING&lt;/code&gt;, PostgreSQL aplica la conversión implícita con la zona de la sesión. En la prueba, con la sesión en &lt;code&gt;America/Bogota&lt;/code&gt;, el valor &lt;code&gt;15:00&lt;/code&gt; se convirtió en &lt;code&gt;20:00+00&lt;/code&gt;; con &lt;code&gt;USING ... AT TIME ZONE &#39;UTC&#39;&lt;/code&gt; quedó en &lt;code&gt;15:00+00&lt;/code&gt;. Este cambio de tipo puede reescribir la tabla, así que planifícalo en tablas grandes.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fija la zona de la sesión de forma consciente.&lt;/strong&gt; Consulta el valor actual con &lt;code&gt;SHOW TimeZone;&lt;/code&gt; y cámbialo para la sesión con &lt;code&gt;SET TimeZone = &#39;UTC&#39;;&lt;/code&gt;. Para que cada sesión nueva de un rol arranque en UTC:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALTER ROLE mi_usuario SET timezone = &#39;UTC&#39;;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La documentación indica que el valor se aplica al iniciar sesión el rol y reemplaza al de &lt;code&gt;postgresql.conf&lt;/code&gt;. En la prueba, &lt;code&gt;SHOW timezone&lt;/code&gt; pasó de &lt;code&gt;America/Bogota&lt;/code&gt; a &lt;code&gt;UTC&lt;/code&gt; en la conexión siguiente.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;p&gt;Esta comprobación crea una tabla de prueba y deja la sesión en la zona local, que es el caso que provoca el desfase:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE prueba_zona (id serial PRIMARY KEY, creado_en timestamp NOT NULL);
SET TimeZone = &#39;America/Bogota&#39;;
INSERT INTO prueba_zona (creado_en) VALUES (now()), (timezone(&#39;UTC&#39;, now()));

SELECT id,
       round(extract(epoch FROM ((creado_en AT TIME ZONE &#39;UTC&#39;) - now())) / 3600, 2) AS desfase_horas
FROM prueba_zona
ORDER BY id;

DROP TABLE prueba_zona;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La fila 1 (&lt;code&gt;now()&lt;/code&gt; sin convertir) debe mostrar un desfase de &lt;code&gt;-5.00&lt;/code&gt; horas, y la fila 2 (&lt;code&gt;timezone(&#39;UTC&#39;, now())&lt;/code&gt;) debe mostrar &lt;code&gt;0.00&lt;/code&gt;. Si tu aplicación calcula una caducidad, comprueba también que el tiempo restante justo después de crear el registro sea exactamente el esperado, sin horas de más ni de menos.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;Valores con desplazamiento escritos como &lt;code&gt;timestamp&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Según la documentación, cuando un valor se interpreta como &lt;code&gt;timestamp without time zone&lt;/code&gt;, PostgreSQL ignora en silencio cualquier indicación de zona de la entrada. Por eso &lt;code&gt;&#39;2026-10-11 15:00:00-05&#39;::timestamp&lt;/code&gt; queda como &lt;code&gt;15:00&lt;/code&gt;, sin ajuste. Para conservar el instante, escribe el tipo completo: &lt;code&gt;TIMESTAMP WITH TIME ZONE &#39;2026-10-11 15:00:00-05&#39;&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Obtener la fecha local, no la de UTC&lt;/h3&gt;
&lt;p&gt;Para agrupar por día local usa la zona de forma explícita: &lt;code&gt;(now() AT TIME ZONE &#39;America/Bogota&#39;)::date&lt;/code&gt;. Si la columna es un &lt;code&gt;timestamp&lt;/code&gt; guardado en UTC, convierte primero con la doble conversión de la solución 1 y después aplica &lt;code&gt;::date&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Prisma, &lt;code&gt;timestamptz&lt;/code&gt; y adaptadores de controlador&lt;/h3&gt;
&lt;p&gt;Al escribir este artículo había una incidencia abierta en el repositorio de Prisma (&lt;code&gt;prisma/orm&lt;/code&gt;, número 26786) que describe lecturas desplazadas con &lt;code&gt;&amp;#64;db.Timestamptz&lt;/code&gt; al usar adaptadores de controlador cuando la zona horaria de PostgreSQL no es UTC. Revisa su estado y prueba con tu versión antes de migrar columnas.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;timezone(zona, valor)&lt;/code&gt; equivale a &lt;code&gt;AT TIME ZONE&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;La documentación indica que la función &lt;code&gt;timezone(zona, valor)&lt;/code&gt; es equivalente a la construcción &lt;code&gt;valor AT TIME ZONE zona&lt;/code&gt;. Fíjate en el orden de los argumentos: la zona va primero.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;En un &lt;code&gt;timestamp without time zone&lt;/code&gt;, &lt;code&gt;AT TIME ZONE &#39;X&#39;&lt;/code&gt; supone que el valor ya está en la zona X y devuelve un &lt;code&gt;timestamptz&lt;/code&gt;; no convierte desde UTC.&lt;/li&gt;
  &lt;li&gt;Para pasar de UTC a hora local: &lt;code&gt;col AT TIME ZONE &#39;UTC&#39; AT TIME ZONE &#39;America/Bogota&#39;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;now()&lt;/code&gt; es &lt;code&gt;timestamptz&lt;/code&gt;; al guardarlo en una columna sin zona se usa la zona de la sesión. Usa &lt;code&gt;timezone(&#39;UTC&#39;, now())&lt;/code&gt; o &lt;code&gt;now() AT TIME ZONE &#39;UTC&#39;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Prefiere &lt;code&gt;timestamptz&lt;/code&gt; en columnas nuevas y migra las antiguas con &lt;code&gt;USING col AT TIME ZONE &#39;UTC&#39;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Revisa &lt;code&gt;SHOW TimeZone&lt;/code&gt; en cada entorno que ejecute migraciones o scripts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/functions-datetime.html&quot;&gt;PostgreSQL: funciones de fecha y hora, sección &lt;code&gt;AT TIME ZONE&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/datatype-datetime.html&quot;&gt;PostgreSQL: tipos de fecha y hora (&lt;code&gt;timestamp&lt;/code&gt; con y sin zona)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.prisma.io/docs/orm/reference/prisma-schema-reference&quot;&gt;Prisma: referencia del esquema, tipo &lt;code&gt;DateTime&lt;/code&gt; y atributos nativos para PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/1422095846712956874/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/postgres-at-time-zone-no-convierte-un.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1422095846712956874'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/1422095846712956874'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/postgres-at-time-zone-no-convierte-un.html' title='Postgres: AT TIME ZONE no convierte un timestamp sin zona'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-7117845174362787190</id><published>2026-10-29T15:00:00.000-05:00</published><updated>2026-10-29T15:00:00.169-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="Git"/><category scheme="http://www.blogger.com/atom/ns#" term="Linux"/><title type='text'>git push | tail devuelve código 0 aunque el push falle</title><content type='html'>&lt;p&gt;Ejecutas el push recortando la salida para no llenar la pantalla, o lo lanzas desde una herramienta que solo informa del código de salida del comando completo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git push origin main 2&amp;gt;&amp;amp;1 | tail -20&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El resultado dice que el comando terminó con código 0 y das el trabajo por subido. Sin embargo, al revisar el servidor el remoto sigue con el commit anterior, y las últimas líneas que viste no explican el motivo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ git push origin main 2&amp;gt;&amp;amp;1 | tail -3
build: paso 39 de 40
build: paso 40 de 40
error: failed to push some refs to &#39;https://example.com/equipo/mi-app.git&#39;
$ echo $?
0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Este artículo explica por qué una tubería (&lt;em&gt;pipe&lt;/em&gt;) oculta el fallo de &lt;code&gt;git push&lt;/code&gt;, cómo obtener el código de salida real y cómo comprobar que el remoto recibió tu commit.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;El manual de Bash define el estado de salida de una tubería como el del último comando, salvo que la opción &lt;code&gt;pipefail&lt;/code&gt; esté activa. En &lt;code&gt;git push ... | tail&lt;/code&gt; el último comando es &lt;code&gt;tail&lt;/code&gt;, que termina bien siempre que pueda imprimir algo. El código de &lt;code&gt;git push&lt;/code&gt; se descarta sin avisar.&lt;/p&gt;
&lt;p&gt;Además, la causa del fallo suele estar en otro lugar. Git ejecuta el hook &lt;code&gt;pre-push&lt;/code&gt; antes de enviar nada y, según su documentación, si ese hook termina con un estado distinto de cero el push se aborta sin enviar nada. Cuando el hook ejecuta lint, comprobación de tipos o un build, puede fallar por motivos del entorno (un permiso denegado sobre un archivo bloqueado, falta de memoria). Su mensaje aparece al principio de la salida, justo la parte que &lt;code&gt;tail -20&lt;/code&gt; recorta.&lt;/p&gt;
&lt;p&gt;En la prueba, con un hook que terminaba con error, &lt;code&gt;git push&lt;/code&gt; devolvió 1, la tubería completa devolvió 0 y el remoto conservó el hash anterior. También se ve el segundo efecto: con &lt;code&gt;tail -3&lt;/code&gt; desapareció la línea con el motivo (un &lt;code&gt;EPERM&lt;/code&gt; simulado) que el hook había impreso al inicio.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;No pases &lt;code&gt;git push&lt;/code&gt; por una tubería.&lt;/strong&gt; Redirige la salida completa a un archivo y lee el código justo después:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git push origin main &amp;gt; push.log 2&amp;gt;&amp;amp;1
echo &quot;git push terminó con código $?&quot;
tail -n 20 push.log&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;El &lt;code&gt;echo&lt;/code&gt; va antes que &lt;code&gt;tail&lt;/code&gt; porque &lt;code&gt;$?&lt;/code&gt; guarda solo el estado del último comando ejecutado. El archivo conserva todo el mensaje, de modo que puedes buscar el motivo con &lt;code&gt;grep -n -i error push.log&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Si necesitas la tubería, activa &lt;code&gt;pipefail&lt;/code&gt;.&lt;/strong&gt; Con esta opción, el estado de la tubería pasa a ser el del último comando (el de más a la derecha) que terminó con un valor distinto de cero:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;set -o pipefail
git push origin main 2&amp;gt;&amp;amp;1 | tail -n 20
echo &quot;código: $?&quot;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;En la prueba imprimió &lt;code&gt;código: 1&lt;/code&gt;. En un script, coloca &lt;code&gt;set -o pipefail&lt;/code&gt; al principio; en una sesión interactiva afecta a todos los comandos siguientes.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;O consulta &lt;code&gt;PIPESTATUS&lt;/code&gt; (variable de Bash).&lt;/strong&gt; Es un arreglo con el estado de cada comando de la última tubería ejecutada en primer plano:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git push origin main 2&amp;gt;&amp;amp;1 | tail -n 20
echo &quot;git push: ${PIPESTATUS[0]}  tail: ${PIPESTATUS[1]}&quot;&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;La salida de la prueba fue &lt;code&gt;git push: 1  tail: 0&lt;/code&gt;. Debes leerla en el comando inmediatamente posterior a la tubería, porque cualquier otro comando la sobrescribe.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Comprueba el remoto, no solo el código.&lt;/strong&gt; &lt;code&gt;git ls-remote&lt;/code&gt; muestra los hashes que tiene el servidor; el primero debe coincidir con el de tu &lt;code&gt;HEAD&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git ls-remote origin refs/heads/main
git rev-parse HEAD&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Otra opción es actualizar la información remota y mirar el estado resumido:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git fetch origin
git status -sb&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si la primera línea muestra &lt;code&gt;## main...origin/main [ahead 1]&lt;/code&gt;, tienes un commit que el remoto no recibió.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Corrige la causa real y repite el push sin tubería.&lt;/strong&gt; Busca en &lt;code&gt;push.log&lt;/code&gt; la primera línea de error y ejecuta a mano los comandos del hook (lint, tipos, build) para reproducirlo. Evita usar &lt;code&gt;git push --no-verify&lt;/code&gt; solo para que el push pase: según la documentación de Git, esa opción omite el hook por completo, y los errores que detectaba llegarán al remoto.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git push origin main &amp;gt; push.log 2&amp;gt;&amp;amp;1; echo &quot;código: $?&quot;
git ls-remote origin refs/heads/main
git rev-parse HEAD
git status -sb&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El código debe ser 0, el hash de &lt;code&gt;ls-remote&lt;/code&gt; debe ser idéntico al de &lt;code&gt;rev-parse&lt;/code&gt; y la primera línea de &lt;code&gt;git status -sb&lt;/code&gt; no debe incluir &lt;code&gt;[ahead ...]&lt;/code&gt;. En la prueba, antes de corregir la causa el hash remoto era distinto del local y el estado mostraba &lt;code&gt;[ahead 1]&lt;/code&gt;; después de eliminar la causa del fallo del hook y empujar sin tubería, el código fue 0, ambos hashes coincidieron y el estado volvió a &lt;code&gt;## main...origin/main&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;PowerShell: usa &lt;code&gt;$LASTEXITCODE&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;git push origin main 2&amp;gt;&amp;amp;1 | Select-Object -Last 20
$LASTEXITCODE&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;En Windows PowerShell 5.1, tras esa tubería &lt;code&gt;$LASTEXITCODE&lt;/code&gt; conservó el código de &lt;code&gt;git&lt;/code&gt; (1), pero &lt;code&gt;$?&lt;/code&gt; valía &lt;code&gt;True&lt;/code&gt; porque el último comando de la tubería terminó bien. Para decidir si el push funcionó, usa &lt;code&gt;$LASTEXITCODE&lt;/code&gt; y no &lt;code&gt;$?&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Cualquier comando seguido de una tubería&lt;/h3&gt;
&lt;p&gt;El mismo problema afecta a &lt;code&gt;npm run build | tail&lt;/code&gt;, &lt;code&gt;docker build . | tail&lt;/code&gt; o &lt;code&gt;pytest | tee resultado.log&lt;/code&gt;. Si el comando de la izquierda falla y el de la derecha no, el resultado visible es éxito. Aplica &lt;code&gt;set -o pipefail&lt;/code&gt; o redirige a un archivo.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; e &lt;code&gt;if&lt;/code&gt; también se engañan&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;git push origin main 2&amp;gt;&amp;amp;1 | tail -n 20 &amp;amp;&amp;amp; echo &quot;subido&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sin &lt;code&gt;pipefail&lt;/code&gt;, este comando imprime &lt;code&gt;subido&lt;/code&gt; aunque el push haya fallado; con &lt;code&gt;pipefail&lt;/code&gt; activo, no lo imprime.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;set -e&lt;/code&gt; no basta&lt;/h3&gt;
&lt;p&gt;En un script con &lt;code&gt;set -e&lt;/code&gt;, un comando que falla en mitad de una tubería no detiene la ejecución, porque el estado que cuenta es el del último. En la prueba, &lt;code&gt;false | cat&lt;/code&gt; no interrumpió el script con &lt;code&gt;set -e&lt;/code&gt; y sí lo hizo con &lt;code&gt;set -eo pipefail&lt;/code&gt;. Para scripts de despliegue o de CI, la combinación habitual es esta última.&lt;/p&gt;
&lt;h3&gt;&quot;Everything up-to-date&quot; también devuelve 0&lt;/h3&gt;
&lt;p&gt;Un push sin cambios nuevos termina con código 0 de forma legítima. Por eso conviene comparar siempre los hashes con &lt;code&gt;git ls-remote&lt;/code&gt; y &lt;code&gt;git rev-parse HEAD&lt;/code&gt;: te dicen si el remoto tiene tu commit, sin depender del texto ni del código.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;El código de salida de &lt;code&gt;git push ... | tail&lt;/code&gt; es el de &lt;code&gt;tail&lt;/code&gt;, no el de &lt;code&gt;git push&lt;/code&gt;; un hook &lt;code&gt;pre-push&lt;/code&gt; fallido queda oculto.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;tail&lt;/code&gt; además recorta el motivo del fallo, que el hook suele imprimir al principio.&lt;/li&gt;
  &lt;li&gt;Evita la tubería: &lt;code&gt;git push ... &amp;gt; push.log 2&amp;gt;&amp;amp;1; echo $?&lt;/code&gt;, o usa &lt;code&gt;set -o pipefail&lt;/code&gt; / &lt;code&gt;${PIPESTATUS[0]}&lt;/code&gt; en Bash y &lt;code&gt;$LASTEXITCODE&lt;/code&gt; en PowerShell.&lt;/li&gt;
  &lt;li&gt;Verifica siempre el resultado real con &lt;code&gt;git ls-remote origin refs/heads/main&lt;/code&gt; frente a &lt;code&gt;git rev-parse HEAD&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://tiswww.case.edu/php/chet/bash/bashref.html&quot;&gt;Manual de referencia de Bash: Pipelines, &lt;code&gt;PIPESTATUS&lt;/code&gt; y la opción &lt;code&gt;pipefail&lt;/code&gt; de &lt;code&gt;set&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/githooks&quot;&gt;Git: &lt;code&gt;githooks&lt;/code&gt;, hook &lt;code&gt;pre-push&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-push&quot;&gt;Git: &lt;code&gt;git-push&lt;/code&gt;, opciones &lt;code&gt;--verify&lt;/code&gt; y &lt;code&gt;--no-verify&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/7117845174362787190/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/git-push-tail-devuelve-codigo-0-aunque.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7117845174362787190'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/7117845174362787190'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/git-push-tail-devuelve-codigo-0-aunque.html' title='git push | tail devuelve código 0 aunque el push falle'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-3279603830404251843.post-3866766675018605875</id><published>2026-10-29T09:00:00.000-05:00</published><updated>2026-10-29T09:00:00.179-05:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Errores y soluciones"/><category scheme="http://www.blogger.com/atom/ns#" term="React"/><category scheme="http://www.blogger.com/atom/ns#" term="Testing"/><title type='text'>Vitest: ReferenceError: ResizeObserver is not defined</title><content type='html'>&lt;p&gt;Añades a la interfaz un componente que usa &lt;code&gt;ResizeObserver&lt;/code&gt; (por ejemplo, una tabla con una barra de desplazamiento horizontal duplicada) y, de golpe, fallan muchos tests que no tienen relación entre sí:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; FAIL  src/TablaConScroll.test.tsx &amp;gt; TablaConScroll &amp;gt; muestra las filas
ReferenceError: ResizeObserver is not defined
 ❯ src/TablaConScroll.tsx:10:22
     10|     const observer = new ResizeObserver(() =&amp;gt; setAncho(el.scrollWidth));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;El componente de ejemplo mide su contenedor al montarse:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/TablaConScroll.tsx
import { useEffect, useRef, useState } from &#39;react&#39;;

export function TablaConScroll({ filas }: { filas: string[] }) {
  const contenedor = useRef&amp;lt;HTMLDivElement&amp;gt;(null);
  const [ancho, setAncho] = useState(0);

  useEffect(() =&amp;gt; {
    const el = contenedor.current;
    if (!el) return;
    const observer = new ResizeObserver(() =&amp;gt; setAncho(el.scrollWidth));
    observer.observe(el);
    return () =&amp;gt; observer.disconnect();
  }, []);

  return (
    &amp;lt;div ref={contenedor} data-ancho={ancho}&amp;gt;
      &amp;lt;table&amp;gt;
        &amp;lt;tbody&amp;gt;
          {filas.map((f) =&amp;gt; (
            &amp;lt;tr key={f}&amp;gt;&amp;lt;td&amp;gt;{f}&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
          ))}
        &amp;lt;/tbody&amp;gt;
      &amp;lt;/table&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Funciona en el navegador, y los tests que no lo renderizan pasan. Este artículo explica por qué falla solo bajo jsdom y cómo resolverlo una vez, para toda la suite, con un archivo de preparación de Vitest.&lt;/p&gt;

&lt;h2&gt;Por qué pasa&lt;/h2&gt;
&lt;p&gt;jsdom es una implementación de la plataforma web para Node.js, pero no incluye todas las APIs de un navegador. Su propio README aclara que no calcula el diseño de la página (&lt;em&gt;layout&lt;/em&gt;) y que devuelve ceros en muchas propiedades relacionadas, como &lt;code&gt;getBoundingClientRect()&lt;/code&gt;. Un observador de tamaño necesita justo ese cálculo, y en el repositorio de jsdom, al escribir este artículo, la solicitud para implementar &lt;code&gt;ResizeObserver&lt;/code&gt; seguía abierta como propuesta de nueva funcionalidad.&lt;/p&gt;
&lt;p&gt;En la comprobación con Vitest 3.2.4 y jsdom 30.0.1, tanto &lt;code&gt;typeof ResizeObserver&lt;/code&gt; como &lt;code&gt;typeof IntersectionObserver&lt;/code&gt; devolvieron &lt;code&gt;undefined&lt;/code&gt;, y &lt;code&gt;getBoundingClientRect().width&lt;/code&gt;, &lt;code&gt;scrollWidth&lt;/code&gt; y &lt;code&gt;offsetWidth&lt;/code&gt; valían 0.&lt;/p&gt;
&lt;p&gt;El error se multiplica porque el componente crea el observador dentro de un &lt;code&gt;useEffect&lt;/code&gt;, que se ejecuta al montarlo. Cualquier test que lo renderice, directamente o dentro de una página o un layout que lo incluya, falla antes de llegar a sus aserciones.&lt;/p&gt;

&lt;h2&gt;Solución&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Crea un archivo de preparación con un mock mínimo.&lt;/strong&gt; La clase solo necesita los métodos que invoca tu código (aquí &lt;code&gt;observe&lt;/code&gt; y &lt;code&gt;disconnect&lt;/code&gt;); se incluyen los tres habituales por si otro componente usa &lt;code&gt;unobserve&lt;/code&gt;. El operador &lt;code&gt;??=&lt;/code&gt; asigna únicamente si el global no existe, así que no pisa la implementación real en entornos que sí la tengan:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// vitest.setup.ts
class ResizeObserverMock {
  observe() {}
  unobserve() {}
  disconnect() {}
}

class IntersectionObserverMock {
  readonly root = null;
  readonly rootMargin = &#39;&#39;;
  readonly thresholds: number[] = [];
  observe() {}
  unobserve() {}
  disconnect() {}
  takeRecords() {
    return [];
  }
}

globalThis.ResizeObserver ??= ResizeObserverMock as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??=
  IntersectionObserverMock as unknown as typeof IntersectionObserver;&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Regístralo en &lt;code&gt;test.setupFiles&lt;/code&gt;.&lt;/strong&gt; Según la documentación de Vitest, la ruta se resuelve respecto a la raíz del proyecto y los archivos se ejecutan antes de cada archivo de tests, en el mismo proceso:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// vitest.config.ts
import { defineConfig } from &#39;vitest/config&#39;;

export default defineConfig({
  test: {
    environment: &#39;jsdom&#39;,
    setupFiles: [&#39;./vitest.setup.ts&#39;],
  },
});&lt;/code&gt;&lt;/pre&gt;
    &lt;p&gt;Si tu configuración de Vitest vive dentro de &lt;code&gt;vite.config.ts&lt;/code&gt;, la clave &lt;code&gt;test&lt;/code&gt; se escribe igual en ese archivo.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cuando un test necesite controlar el observador&lt;/strong&gt;, reemplaza el global solo en ese test con &lt;code&gt;vi.stubGlobal&lt;/code&gt; y restáuralo después. Esta función está pensada para variables globales que no existen en &lt;code&gt;jsdom&lt;/code&gt; o &lt;code&gt;node&lt;/code&gt;; Vitest no las restablece por sí solo, así que llama a &lt;code&gt;vi.unstubAllGlobals()&lt;/code&gt; (o activa la opción &lt;code&gt;unstubGlobals&lt;/code&gt; de la configuración):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { afterEach, expect, it, vi } from &#39;vitest&#39;;
import { act, cleanup, render } from &#39;@testing-library/react&#39;;
import { TablaConScroll } from &#39;./TablaConScroll&#39;;

afterEach(() =&amp;gt; {
  cleanup();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

it(&#39;reacciona cuando el observador notifica un cambio&#39;, () =&amp;gt; {
  let notificar: () =&amp;gt; void = () =&amp;gt; {};

  class ObservadorControlado {
    constructor(callback: ResizeObserverCallback) {
      notificar = () =&amp;gt; callback([], this as unknown as ResizeObserver);
    }
    observe = vi.fn();
    unobserve = vi.fn();
    disconnect = vi.fn();
  }
  vi.stubGlobal(&#39;ResizeObserver&#39;, ObservadorControlado);
  vi.spyOn(HTMLElement.prototype, &#39;scrollWidth&#39;, &#39;get&#39;).mockReturnValue(500);

  const { container } = render(&amp;lt;TablaConScroll filas={[&#39;uno&#39;]} /&amp;gt;);
  act(() =&amp;gt; notificar());

  expect(container.firstElementChild?.getAttribute(&#39;data-ancho&#39;)).toBe(&#39;500&#39;);
});&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;No dependas de medidas reales.&lt;/strong&gt; Como jsdom no hace diseño, las anchuras y alturas son 0 salvo que las simules, como en el ejemplo anterior con &lt;code&gt;scrollWidth&lt;/code&gt;. Los tests deben comprobar qué hace el componente cuando recibe una medida concreta, no cuánto mide en pantalla; para eso están las pruebas en un navegador real.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Alternativa: un polyfill.&lt;/strong&gt; Puedes instalar el paquete &lt;code&gt;resize-observer-polyfill&lt;/code&gt; e importarlo en el archivo de preparación en lugar de escribir el mock. Ten presente que seguirá sin haber medidas reales, porque jsdom no calcula el diseño.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;Cómo verificar que quedó resuelto&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npx vitest run&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La suite debe terminar sin &lt;code&gt;ReferenceError&lt;/code&gt;. En la prueba, antes de añadir el archivo de preparación fallaba el test del componente (&lt;code&gt;Tests  1 failed | 1 passed&lt;/code&gt;) y después pasaron todos los archivos. Como comprobación permanente puedes añadir un test mínimo que detecte si alguien quita el archivo de la configuración:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { expect, it } from &#39;vitest&#39;;

it(&#39;el entorno de tests define los observadores&#39;, () =&amp;gt; {
  expect(typeof ResizeObserver).toBe(&#39;function&#39;);
  expect(typeof IntersectionObserver).toBe(&#39;function&#39;);
});&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Variantes y errores relacionados&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;IntersectionObserver is not defined&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Es el mismo caso: jsdom tampoco lo implementa, y el archivo de preparación de la solución 1 ya lo cubre. Si un test necesita simular que un elemento entra en pantalla, usa &lt;code&gt;vi.stubGlobal&lt;/code&gt; con una clase que guarde el &lt;code&gt;callback&lt;/code&gt; y llámalo a mano, igual que en el ejemplo del &lt;code&gt;ResizeObserver&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;window.matchMedia is not a function&lt;/code&gt; y &lt;code&gt;scrollIntoView is not a function&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;En jsdom 30.0.1, &lt;code&gt;typeof window.matchMedia&lt;/code&gt; y &lt;code&gt;typeof Element.prototype.scrollIntoView&lt;/code&gt; también fueron &lt;code&gt;undefined&lt;/code&gt;. Se resuelven con el mismo patrón:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (typeof window !== &#39;undefined&#39;) {
  window.matchMedia ??= ((query: string) =&amp;gt; ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener() {},
    removeEventListener() {},
    addListener() {},
    removeListener() {},
    dispatchEvent: () =&amp;gt; false,
  })) as unknown as typeof window.matchMedia;

  Element.prototype.scrollIntoView ??= function () {};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La condición &lt;code&gt;typeof window !== &#39;undefined&#39;&lt;/code&gt; importa: los archivos de &lt;code&gt;setupFiles&lt;/code&gt; se ejecutan también en los archivos de tests que usan el entorno &lt;code&gt;node&lt;/code&gt;, donde &lt;code&gt;window&lt;/code&gt; no existe.&lt;/p&gt;
&lt;h3&gt;El error viene de una librería, no de tu código&lt;/h3&gt;
&lt;p&gt;Componentes de terceros (gráficos, listas virtualizadas, menús desplegables) suelen crear observadores internamente. La traza apunta entonces a &lt;code&gt;node_modules&lt;/code&gt;, pero la solución es la misma: definir el global antes de que se monte el componente.&lt;/p&gt;
&lt;h3&gt;Cuidado con ocultar un fallo real&lt;/h3&gt;
&lt;p&gt;Un mock vacío hace que el test pase aunque el componente no reaccione a los cambios de tamaño. Si esa lógica es importante, cúbrela con un test como el de la solución 3, que dispara el &lt;code&gt;callback&lt;/code&gt; a mano y comprueba el resultado.&lt;/p&gt;

&lt;h2&gt;Resumen&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;jsdom no implementa &lt;code&gt;ResizeObserver&lt;/code&gt; ni &lt;code&gt;IntersectionObserver&lt;/code&gt;; cualquier componente que los use al montarse rompe todos los tests que lo rendericen.&lt;/li&gt;
  &lt;li&gt;Define un mock con &lt;code&gt;observe&lt;/code&gt;, &lt;code&gt;unobserve&lt;/code&gt; y &lt;code&gt;disconnect&lt;/code&gt; en un archivo registrado en &lt;code&gt;test.setupFiles&lt;/code&gt;, con &lt;code&gt;globalThis.ResizeObserver ??= ...&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Para controlar el observador en un test concreto, usa &lt;code&gt;vi.stubGlobal&lt;/code&gt; y &lt;code&gt;vi.unstubAllGlobals()&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;En jsdom las medidas de diseño son 0: simula los valores que necesites y no escribas tests que dependan de tamaños reales.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Referencias&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://vitest.dev/config/setupfiles&quot;&gt;Vitest: opción de configuración &lt;code&gt;setupFiles&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://vitest.dev/guide/mocking/globals&quot;&gt;Vitest: simular variables globales con &lt;code&gt;vi.stubGlobal&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/jsdom/jsdom/blob/main/README.md&quot;&gt;jsdom: README, sección sobre las partes no implementadas de la plataforma web&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><link rel='replies' type='application/atom+xml' href='http://tutorialestecnicos.blogspot.com/feeds/3866766675018605875/comments/default' title='Comentarios de la entrada'/><link rel='replies' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/vitest-referenceerror-resizeobserver-is.html#comment-form' title='0 Comentarios'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/3866766675018605875'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/3279603830404251843/posts/default/3866766675018605875'/><link rel='alternate' type='text/html' href='http://tutorialestecnicos.blogspot.com/2026/12/vitest-referenceerror-resizeobserver-is.html' title='Vitest: ReferenceError: ResizeObserver is not defined'/><author><name>Willian Diaz</name><uri>http://www.blogger.com/profile/12030062158428083614</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='//blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgnRio_JU5GHfySNIqpCcLO4y39mwgQIKFiBFgFSEQHIpWJYxrNWwTtCRsCWKvSrU8_dQZpSm47fY5wy_IaRQhTFv8Slg09OkhVgVSCrIXBTem182d5Y7oG4kLjErTovag/s113/1676985165043%3Fe%3D1683158400%26v%3Dbeta%26t%3DnDgfb7d630JSwVrA7iVRo0peOk3OtnsWUx7kcBUFUIg'/></author><thr:total>0</thr:total></entry></feed>