Clipboard API falla en TypeScript: los 4 casos que nadie documenta y cómo los encontré en mi código
En 2007, cuando administraba servidores de web hosting a los 18 años, el CTO me enseñó algo que tardé años en generalizar: los errores que más te queman no son los que gritan, son los que se comen en silencio. Me tiré un servidor de producción con rm -rf y el tipo ni se enojó, me dijo "bien, ahora sí lo vas a recordar". Tenía razón. Lo que me costó más caro no fue ese error ruidoso — fue la semana siguiente, cuando empecé a confiar en que si no había error visible, todo andaba bien.
Hoy, casi veinte años después, me encontré con la misma trampa pero en TypeScript. navigator.clipboard.writeText() devuelve una Promise. La Promise se rechaza en silencio. El usuario hace clic en "Copiar" y no pasa nada. Cero feedback, cero error en consola, cero pista. Y yo con un componente que funcionaba perfecto en mi máquina.
Mi tesis: copyToClipboard falla en TypeScript no porque la API sea mala, sino porque tiene cuatro precondiciones no documentadas que la mayoría de los tutoriales omiten por completo. Si no las manejás explícitamente, vas a tener un botón de copiar roto en producción sin saberlo.
Por qué puede fallar copyToClipboard en TypeScript: el mapa completo
Antes de entrar en los casos, el contexto: navigator.clipboard es la Clipboard API asíncrona moderna. Es la reemplazante del viejo document.execCommand('copy') que ya está deprecado. Pero la modernidad trae restricciones de seguridad que los ejemplos de tres líneas no te cuentan.
El problema no es la API en sí — es que tiene cuatro precondiciones duras que, si no se cumplen simultáneamente, la Promise se rechaza. Y el rechazo, si no lo atrapás, desaparece.
// El código que todos usan y que falla silenciosamente
const copiarAlPortapapeles = async (texto: string) => {
await navigator.clipboard.writeText(texto); // 💥 puede rechazarse sin ruido
};
Ese snippet tiene cuatro bombas de tiempo. Vamos una por una.
Caso 1: el contexto inseguro (HTTPS vs HTTP, y el iframe que nadie menciona)
El más documentado de los cuatro, pero igual lo veo en producción cada dos semanas.
navigator.clipboard solo funciona en contextos seguros: HTTPS, localhost, o extensiones de navegador. En HTTP, navigator.clipboard directamente es undefined. Hasta ahí, conocido. Lo que nadie menciona es el caso del iframe cross-origin.
Estaba construyendo un widget embebible para un cliente. El widget se servía desde mi dominio en HTTPS. El sitio que lo embebía también usaba HTTPS. Pero el iframe era cross-origin. Resultado: navigator.clipboard disponible, pero writeText rechazado con NotAllowedError. Sin warning previo, sin nada.
// Verificación robusta de contexto seguro
const esContextoSeguro = (): boolean => {
// window.isSecureContext cubre HTTPS, localhost y extensiones
if (!window.isSecureContext) return false;
// navigator.clipboard puede existir pero estar restringido en iframes cross-origin
if (!navigator.clipboard) return false;
return true;
};
const copiarConGuardia = async (texto: string): Promise<boolean> => {
if (!esContextoSeguro()) {
// Fallback al método legacy antes de rendirse
return copiarLegacy(texto);
}
try {
await navigator.clipboard.writeText(texto);
return true;
} catch (error) {
console.warn('[Clipboard] writeText rechazado:', error);
return copiarLegacy(texto);
}
};
// Fallback con execCommand (deprecado pero funcional todavía)
const copiarLegacy = (texto: string): boolean => {
const elemento = document.createElement('textarea');
elemento.value = texto;
elemento.style.position = 'fixed';
elemento.style.opacity = '0';
document.body.appendChild(elemento);
elemento.focus();
elemento.select();
try {
const exito = document.execCommand('copy');
document.body.removeChild(elemento);
return exito;
} catch {
document.body.removeChild(elemento);
return false;
}
};
La regla práctica: siempre implementá el fallback legacy. No porque execCommand sea mejor, sino porque es la red de seguridad para contextos donde la Clipboard API tiene restricciones de sandboxing que no controlás.
Caso 2: el foco de ventana perdido (el más tricky en React)
Este me costó tres horas. Tenía un componente que abría un modal, el usuario hacía clic en "Copiar código", y el botón no hacía nada. En mi máquina andaba bien. En producción, silencio.
La Clipboard API requiere que la ventana del navegador tenga el foco activo en el momento del llamado. Si la ventana perdió el foco — por un blur event, por un modal mal implementado, por un setTimeout que ejecuta fuera del contexto de interacción del usuario — el browser rechaza la operación.
En React, el patrón que me rompió todo fue este:
// ❌ Patrón roto: el setTimeout rompe la relación con el evento de usuario
const ManejadorRoto = () => {
const copiar = () => {
setTimeout(async () => {
// En este punto ya no hay "user gesture" activo
// El browser rechaza la Clipboard API
await navigator.clipboard.writeText('algo');
}, 100);
};
return <button onClick={copiar}>Copiar</button>;
};
// ✅ Patrón correcto: ejecución síncrona dentro del evento
const ManejadorCorrecto = () => {
const [copiado, setCopiado] = useState(false);
const copiar = async () => {
// Sin setTimeout, sin delays, directo dentro del handler
try {
await navigator.clipboard.writeText('algo');
setCopiado(true);
// Reset visual después de copiar — acá sí podemos usar setTimeout
setTimeout(() => setCopiado(false), 2000);
} catch (error) {
// El error llega acá, no desaparece
console.error('[Clipboard] Falló writeText:', error);
}
};
return (
<button onClick={copiar}>
{copiado ? '✓ Copiado' : 'Copiar'}
</button>
);
};
El browser considera que una operación de clipboard es segura solo si se origina directamente en un gesto del usuario. Cualquier intermediario asíncrono que no sea la propia Promise de writeText puede cortar esa cadena.
Caso 3: permisos revocados en iOS Safari (el caso que más bronca me da)
Este es el que me tiene en modo frustrado-constructivo hoy. iOS Safari tiene su propio modelo de permisos para clipboard que no sigue el estándar de la Permissions API de Chrome/Firefox.
En Chrome puedo hacer esto:
// Verificar estado de permiso ANTES de intentar escribir
const verificarPermisoClipboard = async (): Promise<PermissionState> => {
try {
const resultado = await navigator.permissions.query({
name: 'clipboard-write' as PermissionName
});
return resultado.state; // 'granted' | 'denied' | 'prompt'
} catch {
// Safari no soporta clipboard-write en permissions.query
// Devuelve 'granted' como suposición optimista
return 'granted';
}
};
En iOS Safari, navigator.permissions.query({ name: 'clipboard-write' }) lanza una excepción. El permiso de escritura en clipboard no existe como permiso consultable — Safari lo maneja de forma implícita y lo ata estrictamente al gesto del usuario. Si el gesto no es lo suficientemente "fresco" (el browser tiene un timeout interno no documentado), la operación falla.
// Wrapper que maneja el comportamiento divergente entre browsers
const escribirEnClipboard = async (texto: string): Promise<{ exito: boolean; metodo: string }> => {
// Intento 1: Clipboard API moderna
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(texto);
return { exito: true, metodo: 'clipboard-api' };
} catch (errorModerno) {
// En iOS esto puede ser un NotAllowedError por timing
console.warn('[Clipboard] API moderna falló, intentando fallback:', errorModerno);
}
}
// Intento 2: execCommand legacy
try {
const exito = copiarLegacy(texto);
return { exito, metodo: 'exec-command' };
} catch (errorLegacy) {
console.error('[Clipboard] Ambos métodos fallaron:', errorLegacy);
return { exito: false, metodo: 'ninguno' };
}
};
Lo que aprendí de iOS: no confiés en que el permiso está dado aunque el usuario acabe de hacer clic. Si hay cualquier microtarea o Promise intermedia entre el clic y el writeText, Safari puede invalidar el contexto de gesto.
Caso 4: el TypeScript que compila pero explota en runtime
Este es el más sutil y el que más me divierte documentar, porque es puro TypeScript siendo TypeScript.
Los tipos de lib.dom.d.ts para navigator.clipboard asumen que navigator.clipboard existe. Pero en browsers viejos o en SSR (Next.js, Remix), navigator directamente no existe en el contexto de ejecución.
// ❌ Esto compila perfecto y explota en Next.js con SSR
const MiComponente = () => {
useEffect(() => {
// Acá sí está bien porque useEffect es client-only
navigator.clipboard.writeText('algo');
}, []);
// ❌ Pero esto explota durante el render del servidor
const esSoportado = !!navigator.clipboard; // ReferenceError en Node.js
return <div>{esSoportado ? 'Soportado' : 'No soportado'}</div>;
};
// ✅ Hook con guardia de SSR y tipado explícito
const useClipboard = () => {
const [copiado, setCopiado] = useState(false);
const [error, setError] = useState<string | null>(null);
// Verificación lazy: solo corre en el cliente
const clipboardSoportado = (): boolean => {
if (typeof window === 'undefined') return false;
if (typeof navigator === 'undefined') return false;
return !!navigator.clipboard && window.isSecureContext;
};
const copiar = async (texto: string): Promise<void> => {
setError(null);
if (!clipboardSoportado()) {
// Intentar fallback silenciosamente
const exito = copiarLegacy(texto);
if (!exito) {
setError('Clipboard no disponible en este contexto');
} else {
setCopiado(true);
setTimeout(() => setCopiado(false), 2000);
}
return;
}
try {
await navigator.clipboard.writeText(texto);
setCopiado(true);
setTimeout(() => setCopiado(false), 2000);
} catch (e) {
const mensaje = e instanceof Error ? e.message : 'Error desconocido';
setError(mensaje);
console.error('[useClipboard] Error:', e);
}
};
return { copiar, copiado, error, soportado: clipboardSoportado };
};
Lo que nadie te dice en los tutoriales de Next.js: si accedés a navigator fuera de un useEffect o fuera de un handler de evento, vas a tener un ReferenceError en el servidor y ni siquiera vas a ver el componente renderizarse.
Esto me lo encontré cuando empecé a armar componentes más complejos con validaciones de features. La misma dinámica de "compila bien, explota en runtime" la vi con Supply Chain attacks en dependencias de npm — si te interesa ese patrón de falla silenciosa, lo desarrollé en detalle en este post sobre simulación de supply chain attack en Node.
Los errores comunes que nadie menciona en Stack Overflow
Error 1: atrapar el error pero no manejarlo
// ❌ El catch existe pero no hace nada útil
try {
await navigator.clipboard.writeText(texto);
} catch (e) {
// No pasa nada aquí
}
El usuario sigue sin saber que la copia falló. El feedback visual es tan importante como el manejo del error.
Error 2: no testear con HTTPS en desarrollo
localhost funciona. http://192.168.1.x:3000 no. Si testeás en la red local con HTTP, navigator.clipboard va a ser undefined y vas a pensar que tu código anda hasta que lo deployás.
Error 3: asumir que el permiso se mantiene entre sesiones
En algunos browsers, el permiso de clipboard-write puede ser revocado si el usuario no interactuó con la página por un tiempo. No es frecuente, pero ocurre. El pattern de siempre tener fallback cubre esto.
Error 4: usar writeText dentro de un useEffect con dependencias vacías
// ❌ Esto no tiene un gesto de usuario — va a fallar
useEffect(() => {
navigator.clipboard.writeText(valorInicial); // Sin clic, sin gesto
}, []);
La Clipboard API no está diseñada para escritura automática. Necesita ser iniciada por el usuario.
La complejidad de manejar estados asíncronos que pueden fallar silenciosamente me recuerda a los patrones de deadlock que diagnostiqué en producción — en ambos casos el problema no es el código que grita, es el que se congela.
FAQ: Por qué puede fallar copyToClipboard en TypeScript
¿Por qué navigator.clipboard es undefined en mi app?
Hay tres causas posibles: estás en un contexto HTTP (no HTTPS), estás en un iframe cross-origin sin el atributo allow="clipboard-write", o estás ejecutando código que accede a navigator durante SSR en Next.js o Remix donde navigator no existe. La verificación typeof navigator !== 'undefined' && window.isSecureContext cubre los tres casos.
¿Por qué copyToClipboard funciona en localhost pero falla en producción?
localhost es considerado un contexto seguro por el browser aunque no use HTTPS. En producción sin HTTPS, navigator.clipboard directamente no está disponible. Si tu producción es HTTPS y sigue fallando, revisá si el componente está dentro de un iframe cross-origin — ese es el caso más común que no aparece en los error logs.
¿Por qué Safari iOS rechaza la Clipboard API aunque el usuario hizo clic?
iOS Safari tiene un timeout implícito para el "user gesture context". Si entre el clic y el writeText hay cualquier operación asíncrona que no sea la propia Promise de writeText — un fetch, un setTimeout, una consulta a un estado — Safari puede invalidar el contexto de gesto y rechazar la operación. La solución es ejecutar writeText lo más directo posible dentro del handler del evento.
¿Cuándo debo usar el fallback con execCommand('copy')?
Siempre que implementes clipboard, aunque execCommand esté deprecado. El fallback cubre iOS Safari en versiones viejas, iframes con sandboxing estricto, HTTP sin posibilidad de migrar a HTTPS, y WebViews embebidos en apps nativas donde las APIs modernas pueden no estar disponibles. El costo de implementarlo es mínimo comparado con el de tener un botón roto en producción.
¿Cómo testeo que mi implementación maneja todos los casos?
Tres escenarios obligatorios: (1) abrí http://localhost:3000 en modo incógnito y verificá que el fallback funciona; (2) serví la app en HTTP puro desde la red local y confirmá que el legacy fallback activa; (3) en Chrome DevTools, usá el panel de Permissions para revocar el permiso de clipboard y verificá que el error se maneja con feedback al usuario. Si tenés acceso a un iPhone físico, probá el componente desde un dominio real — el simulador de Safari en macOS no replica el comportamiento de iOS.
¿Hay una librería que resuelva esto de una vez?
Sí, copy-to-clipboard y use-copy-to-clipboard para React manejan varios de estos casos. Pero te recomiendo implementar la versión propia al menos una vez antes de usar una librería — los wrappers de terceros tienen sus propios edge cases y si no entendés las restricciones de la API, vas a tardar el doble en diagnosticar cuando fallen. Es la misma lógica que aplico a los guardrails para agentes autónomos en producción: nunca delegues la seguridad a algo que no entendés.
Conclusión: el botón de copiar roto es un problema de arquitectura, no de sintaxis
La Clipboard API no es difícil. Tiene cuatro restricciones concretas y todas tienen solución. Lo que sí es un problema es la cultura del "si no hay error en consola, funciona" — que en este caso específico te deja con un feature silenciosamente roto.
Lo que acepto como trade-off honesto: el fallback con execCommand es sucio, está deprecado, y en algún momento va a desaparecer. Pero hasta que iOS Safari alinee su modelo de permisos con el estándar y hasta que los iframes cross-origin tengan mejor soporte, lo necesitamos.
Lo que no compro: los tutoriales de tres líneas que muestran navigator.clipboard.writeText sin manejo de errores ni fallback. Eso no es un ejemplo mínimo, es un ejemplo roto.
El hook useClipboard que armé en el Caso 4 cubre los cuatro escenarios documentados acá. Si lo implementás, vas a tener feedback explícito cuando falle, fallback automático, y tipado TypeScript que no te miente sobre si el contexto es seguro.
Lo mismo que aprendí aquella semana en 2007 con el rm -rf: los errores silenciosos son los que más duelen. La diferencia es que hoy tengo herramientas para hacerlos hablar.
Si te interesa el patrón de fallas silenciosas en producción, también documenté los edge cases de async Rust que el post de HN no predijo y los números reales de Docker Compose después de 30 días en producción.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)