Skip to main content
Volver al blog
JavaScriptWebThird-Party ScriptsFrontendPatterns

Captura un valor de terceros en el instante en que llega a window

Un script que no controlas escribe su resultado en un global de window en un momento que no puedes predecir. La mayoría del código compite contra él con un timeout o desperdicia el main thread haciendo polling. Aquí está el truco de accessor property que convierte la escritura en un evento — además del getter que no debes olvidar, y cómo sustituir el valor por completo.

Publicado 27 de julio de 20269 min de lectura

Tarde o temprano integras un script que no es tuyo — un SDK de atribución, una herramienta de consentimiento, un framework de A/B testing, un resolver de deep links insertado mediante un tag manager — y este se comunica con tu página asignando un valor a un global en window. Algo como window.SMART_LINK_RESULT = { url: … }. Tu trabajo es leer ese valor y actuar sobre él en cuanto exista.

La trampa: no tienes ningún control sobre cuándo ocurre esa asignación. Puede pasar antes de que se ejecute tu código, unos cientos de milisegundos después, o — si la red va lenta o el script nunca llega a cargar — nunca. Este artículo recorre los dos enfoques a los que todo el mundo recurre primero, por qué ambos son compromisos, y un tercero que es estrictamente mejor: interceptar la propia asignación con un accessor property.

El escenario: un valor que llega cuando le da la gana

En concreto, imagina una página de redirección ligera. Un tag manager inyecta un script que calcula la URL de destino correcta, con la atribución completa, y, cuando termina, la escribe en window.SMART_LINK_RESULT. Quieres redirigir a esa URL en el momento en que esté disponible, y recurrir a una URL por defecto si nunca aparece.

El valor es un objeto pequeño, y no somos dueños del script que lo produce — solo conocemos la forma que esperamos:

global.d.ts
// A third-party script assigns this global once it has computed a result.
// We don't own the script, only the type we expect on the window.
interface SmartLinkResult {
  url?: string;
  [key: string]: unknown;
}

declare global {
  interface Window {
    SMART_LINK_RESULT?: SmartLinkResult;
  }
}

export {};

Intento 1: un timeout fijo

El primer instinto es esperar un tiempo "seguro" a que el script termine, luego leer el global una sola vez e ir:

attempt-1-timeout.ts
const FALLBACK_URL = "https://example.com/download";

// Wait a "safe" amount of time, then read the global once and redirect.
setTimeout(() => {
  const url = window.SMART_LINK_RESULT?.url;
  window.location.href = url?.trim() ? url : FALLBACK_URL;
}, 3000);

Funciona con la frecuencia suficiente como para llegar a producción, y luego te cuesta en silencio. Una única lectura detrás de un retraso fijo es una carrera que pierdes en ambas direcciones:

  • Demasiado pronto: si el script no ha escrito el valor para la marca de los 3 segundos, lees undefined y rediriges al fallback — descartando la URL correcta que habría llegado un momento después.
  • Demasiado tarde: si el script terminó en 200 ms, igualmente haces que el usuario se quede mirando una página de redirección en blanco durante los 3 segundos completos antes de hacer nada.
  • El retraso "correcto" es imposible de saber, porque depende de la red y del tercero — así que terminas eligiendo un número que es a la vez demasiado largo para los clientes rápidos y demasiado corto para los lentos.

Intento 2: polling

La solución obvia para la mitad de "demasiado tarde" es dejar de esperar un momento fijo y, en su lugar, comprobar repetidamente hasta que el valor aparezca, con un plazo límite para rendirse:

attempt-2-polling.ts
const FALLBACK_URL = "https://example.com/download";
const DEADLINE = 3000;
let elapsed = 0;

const timer = setInterval(() => {
  const url = window.SMART_LINK_RESULT?.url;

  if (url?.trim()) {
    clearInterval(timer);
    window.location.href = url;           // finally showed up
  } else if ((elapsed += 50) >= DEADLINE) {
    clearInterval(timer);
    window.location.href = FALLBACK_URL;  // gave up waiting
  }
}, 50);

Esto es mejor — reacciona dentro de un intervalo desde que llega el valor, en lugar de esperar todo el retraso. Pero sigue siendo un compromiso: estás ejecutando un timer que no hace nada útil en la mayoría de sus ticks, has cambiado latencia por un intervalo ajustable (demasiado grueso se siente con lag, demasiado fino quema el main thread), y estás leyendo una propiedad de forma activa en un bucle constante para algo que sucede exactamente una vez.

El truco: intercepta la propia escritura

Ambos enfoques tratan la asignación como algo que hay que descubrir después de los hechos — adivinando cuándo ocurrió, o preguntando una y otra vez si ya ocurrió. Pero una asignación a una propiedad es algo que JavaScript te avisará encantado, si defines la propiedad como un accessor antes de que se ejecute el script. Reemplaza el slot de datos normal en window.SMART_LINK_RESULT por un par getter/setter:

interceptor.ts
// Store the current value in a closure so we control what the property holds.
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    value = next; // keep it readable for whoever assigned it
    if (next?.url?.trim()) {
      window.location.href = next.url; // fires the instant the script writes
    }
  },
});

Ahora, en el momento en que el script de terceros ejecuta window.SMART_LINK_RESULT = …, no está escribiendo en un slot normal — está llamando a tu función set, de forma sincrónica, con el valor como argumento. Sin retraso, sin polling, sin adivinar. Reaccionas en el instante exacto en que el valor existe, ni un tick antes ni un tick después. El timeout deja de ser tu mecanismo principal y se convierte en lo que debió haber sido desde el principio: un fallback para el caso en que el valor nunca llegue.

Un timeout fijo adivina cuándo llega el valor. El polling pregunta repetidamente si ya llegó. A un accessor property simplemente se le avisa — la asignación se convierte en un callback sincrónico, con cero latencia y cero trabajo desperdiciado.

Por qué el getter no es opcional

Es tentador definir solo un set — al fin y al cabo, reaccionar a la escritura es todo el objetivo. No lo hagas. Un accessor property con setter pero sin getter devuelve undefined en cada lectura. El script de terceros (y cualquier otra cosa en la página) a menudo vuelve a leer su propio global — para comprobarlo, para actualizar un campo en él, para pasarlo a otro módulo. Un accessor solo de escritura rompe todo eso en silencio:

set-only.ts
// A set-only accessor silently swallows the value on read.
Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  set(next: SmartLinkResult | undefined) {
    if (next?.url) window.location.href = next.url;
  },
});

// The third-party script does its normal thing:
window.SMART_LINK_RESULT = { url: "/go" };

// ...but now anyone reading it back — including the script itself — sees nothing:
console.log(window.SMART_LINK_RESULT); // undefined

La regla es simple: si interceptas escrituras, también debes servir lecturas. Guarda el último valor en una variable de closure, devuélvelo desde get, y actualízalo en set. Para todos los demás, la propiedad se comporta exactamente como la propiedad de datos normal que reemplazó — solo has añadido un efecto secundario en la asignación.

Cierra la carrera por el otro lado: la comprobación temprana

Un interceptor solo captura las escrituras que ocurren después de instalarlo. Si el script de terceros es rápido — o se insertó inline antes que tu código — el valor podría estar ya en el window para cuando te ejecutas. Así que compruébalo primero, e instala el accessor solo si todavía no está ahí:

early-check.ts
// The value may already be on the window before our code runs.
// Check first, and only install the interceptor if it isn't there yet.
const existing = window.SMART_LINK_RESULT?.url;

if (existing?.trim()) {
  window.location.href = existing;
} else {
  installInterceptor(); // the Object.defineProperty from above
}

Con la comprobación temprana y el setter juntos, has cubierto toda la línea de tiempo: el valor ya estaba ahí (lo lees ahora), o llega más tarde (se dispara el setter), o nunca llega (el fallback timer). No hay ninguna ventana en la que un valor real pueda escaparse sin que lo notes.

Sustituir el valor, no solo observarlo

Como el almacenamiento de la propiedad ahora vive en tu closure, no estás limitado a observar el valor — decides qué contiene realmente. Esa es la diferencia entre un listener y un interceptor. Puedes normalizar un payload malformado, eliminar un campo, o reemplazar el valor por defecto del tercero con el tuyo antes de que nadie más adelante llegue a leerlo:

substitute.ts
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    // Don't store what they sent verbatim — normalize it, or replace it entirely.
    value = normalize(next) ?? { url: FALLBACK_URL };
  },
});

Lo que sea que el script asigne fluye a través de tu set, y lo que sea que tu get devuelva es lo que ve el resto de la página. El valor por defecto que el script pretendía instalar nunca tiene que hacerse efectivo — solo el valor que tú decidas conservar.

Limpieza: devuelve la propiedad a su estado original

Un accessor instalado en un global sobrevive al componente que lo añadió, así que desmantélalo cuando termines — especialmente en single-page apps, React Strict Mode, o en cualquier lugar donde el mismo código pueda ejecutarse dos veces. Elimina el accessor y, si había un valor, restáuralo como una propiedad de datos normal:

teardown.ts
function uninstall() {
  const current = value;          // whatever the accessor last held
  delete window.SMART_LINK_RESULT; // remove the getter/setter pair
  if (current !== undefined) {
    // Put it back as an ordinary data property so nothing downstream breaks.
    window.SMART_LINK_RESULT = current;
  }
}

Restaurar una propiedad normal (en lugar de dejar un getter/setter colgando, o eliminar el valor directamente) significa que cualquier cosa que lea el global después recupera el comportamiento normal. Establecer configurable: true al definir la propiedad es lo que hace posible este delete en primer lugar — sin ello, el accessor es permanente.

Uniendo todo

Aquí está todo el patrón como un único hook de React: comprobación temprana, interceptor con su getter correspondiente, un fallback timer, y una limpieza que restaura una propiedad normal. Una traba done garantiza que la redirección se dispare exactamente una vez, sin importar qué camino gane.

useSmartLinkRedirect.ts
import { useEffect } from "react";

const FALLBACK_URL = "https://example.com/download";
const FALLBACK_DELAY = 3000;

export function useSmartLinkRedirect() {
  useEffect(() => {
    let done = false;
    let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
    let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

    const go = (url: string) => {
      if (done) return;
      done = true;
      if (fallbackTimer) clearTimeout(fallbackTimer);
      window.location.href = url;
    };

    // 1. Already resolved before we mounted? Go now.
    if (value?.url?.trim()) {
      go(value.url);
      return;
    }

    // 2. Otherwise, catch the assignment the moment it happens.
    Object.defineProperty(window, "SMART_LINK_RESULT", {
      configurable: true,
      get() {
        return value;
      },
      set(next: SmartLinkResult | undefined) {
        value = next;
        if (next?.url?.trim()) go(next.url);
      },
    });

    // 3. Never trust the third party to always deliver.
    fallbackTimer = setTimeout(() => go(FALLBACK_URL), FALLBACK_DELAY);

    // 4. Restore a plain property on unmount.
    return () => {
      if (fallbackTimer) clearTimeout(fallbackTimer);
      const current = value;
      delete window.SMART_LINK_RESULT;
      if (current !== undefined) window.SMART_LINK_RESULT = current;
    };
  }, []);
}

La misma forma funciona fuera de React — las piezas en movimiento son una variable de closure, un Object.defineProperty con get/set, una comprobación temprana, un fallback, y un teardown. En un módulo simple lo envolverías en una función y llamarías al teardown en pagehide o cuando tu feature termine.

Timeout vs polling vs interceptor

AspectoTimeout fijoPollingInterceptor con accessor
Reacciona en el instante en que se establece el valorNo — espera todo el retrasoCasi — dentro de un intervaloSí — sincrónico
Tiempo desperdiciado cuando el valor llega prontoTodo el retrasoHasta un intervaloNinguno
Costo en el main thread mientras esperaNingunoLecturas repetidas con un timerNinguno
Captura un valor establecido antes de empezarSí (lee una vez al final)Solo con una comprobación temprana explícita
Puede sustituir o normalizar el valorNoNo
Maneja el caso en que el valor nunca llegaSí (lee, recurre al fallback)Sí (rama de plazo límite)Sí (con un fallback timer)

El interceptor gana en cada fila relacionada con reaccionar a la escritura, y empata en el resto en cuanto añades la comprobación temprana y el fallback timer que necesita de todos modos. Lo único que hay que recordar es que debe instalarse antes de que se ejecute el script de terceros — lo cual, para un valor entregado por un tag manager o un SDK asíncrono, casi siempre se cumple si lo instalas temprano en el ciclo de vida de tu página.

Trampas que vale la pena conocer

  • El orden de instalación importa. El interceptor solo captura las escrituras que llegan después de él. Define el accessor tan pronto como sea posible, y combínalo siempre con la comprobación temprana para el valor que se te adelantó.
  • Establece siempre configurable: true. Sin ello no puedes hacer delete de la propiedad para limpiar, y un segundo intento de redefinirla lanza un error.
  • Protégete para SSR. No hay window en el servidor. En un framework, ejecuta esto en un efecto solo de cliente; en un módulo simple, protégete con "undefined" !== "undefined".
  • Un interceptor por propiedad. Si dos partes de tu app redefinen el mismo global, la segunda pisa a la primera. Centralízalo, o comprueba Object.getOwnPropertyDescriptor(window, name) antes de instalar.
  • Algunos globales ya son accessors. Una propiedad definida por el entorno como non-configurable (o con su propio getter/setter) no se puede reemplazar — comprueba el descriptor primero y recurre al polling si de verdad no puedes redefinirla.
  • Mantén get y set baratos. Se ejecutan en cada lectura y escritura del global. Haz el trabajo pesado una sola vez, detrás de tu traba done — no en cada acceso.

Cuándo recurrir a esto

Esta es una herramienta de precisión, no una opción por defecto. Se justifica cuando se cumple todo lo siguiente:

  • Un valor que necesitas se escribe en un global (o en cualquier propiedad de un objeto) mediante código que no controlas.
  • El timing es impredecible y quieres reaccionar sin latencia — o necesitas sustituir el valor antes de que cualquier otra cosa lo lea.
  • Puedes ejecutar tu código antes de que lo haga quien escribe el valor, o estás dispuesto a combinarlo con una comprobación temprana para el caso en que no puedas.

Conclusiones clave

  • Un tercero a menudo te entrega un valor asignando un global en un momento que no puedes predecir; un timeout fijo compite contra ese momento y el polling quema el main thread.
  • Object.defineProperty(window, name, { get, set }), instalado antes de que se ejecute quien escribe el valor, convierte la asignación en un callback sincrónico — cero latencia, cero trabajo desperdiciado.
  • Nunca lances a producción un accessor solo de escritura: combina cada set con un get que devuelva el valor almacenado, o romperás en silencio la relectura del propio script que escribe el valor.
  • Añade una comprobación temprana para un valor que se estableció antes de que instalaras el accessor, y un fallback para un valor que nunca llega.
  • Como tú controlas el almacenamiento, puedes normalizar o reemplazar el valor — un interceptor, no solo un listener.
  • Establece configurable: true y restaura una propiedad de datos normal en la limpieza, para que nada más adelante se quede con una sorpresa.

Nada de esto es exótico — es solo Object.defineProperty apuntado a un global en lugar de a tus propios objetos. Pero replantear "esperar un valor" como "que me avisen cuando se escriba el valor" elimina toda una clase de bugs de timing, y te entrega el valor lo bastante pronto como para cambiarlo. La próxima vez que te encuentres elegiendo un número mágico de timeout para esperar a un script de terceros, recurre al setter en su lugar.