Enrutamiento moderno del cliente: la API de Navigation

Se estandariza el enrutamiento del cliente a través de una API nueva que revisa por completo la compilación de aplicaciones de una sola página.

Navegadores compatibles

  • Chrome: 102.
  • Edge: 102.
  • Firefox: No es compatible.
  • Safari: No se admite.

Origen

Las aplicaciones de una sola página, o SPA, se definen por una característica principal: reescriben su contenido de forma dinámica a medida que el usuario interactúa con el sitio, en lugar del método predeterminado de cargar páginas completamente nuevas desde el servidor.

Si bien las SPAs pudieron ofrecerte esta función a través de la API de History (o, en casos limitados, ajustando la parte #hash del sitio), es una API torpe desarrollada mucho antes de que las SPAs fueran la norma, y la Web necesita un enfoque completamente nuevo. La API de Navigation es una API propuesta que revisa por completo este espacio, en lugar de intentar simplemente corregir los problemas de la API de History. (por ejemplo, Restauración del desplazamiento corrigió la API de History en lugar de intentar reinventarla).

En esta publicación, se describe la API de Navigation de forma general. Para leer la propuesta técnica, consulta el informe de borrador en el repositorio de WICG.

Ejemplo de uso

Para usar la API de Navigation, primero agrega un objeto de escucha "navigate" en el objeto navigation global. Este evento es fundamentalmente centralizado: se activará para todos los tipos de navegaciones, ya sea que el usuario haya realizado una acción (como hacer clic en un vínculo, enviar un formulario o retroceder y avanzar) o cuando la navegación se active de forma programática (es decir, a través del código de tu sitio). En la mayoría de los casos, permite que tu código anule el comportamiento predeterminado del navegador para esa acción. En el caso de las SPA, es probable que eso signifique mantener al usuario en la misma página y cargar o cambiar el contenido del sitio.

Se pasa un NavigateEvent al objeto de escucha "navigate" que contiene información sobre la navegación, como la URL de destino, y te permite responder a la navegación en un lugar centralizado. Un objeto de escucha "navigate" básico podría verse de la siguiente manera:

navigation.addEventListener('navigate', navigateEvent => {
  // Exit early if this navigation shouldn't be intercepted.
  // The properties to look at are discussed later in the article.
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname === '/') {
    navigateEvent.intercept({handler: loadIndexPage});
  } else if (url.pathname === '/cats/') {
    navigateEvent.intercept({handler: loadCatsPage});
  }
});

Puedes controlar la navegación de una de estas dos maneras:

  • Llamar a intercept({ handler }) (como se describió anteriormente) para controlar la navegación
  • Llamar a preventDefault(), que puede cancelar la navegación por completo

En este ejemplo, se llama a intercept() en el evento. El navegador llama a tu devolución de llamada handler, que debería configurar el siguiente estado de tu sitio. Esto creará un objeto de transición, navigation.transition, que otro código puede usar para hacer un seguimiento del progreso de la navegación.

Por lo general, se permiten intercept() y preventDefault(), pero hay casos en los que no se puede llamar a ellos. No puedes controlar las navegaciones a través de intercept() si la navegación es de origen cruzado. Tampoco puedes cancelar una navegación a través de preventDefault() si el usuario presiona los botones Atrás o Adelante en su navegador. No deberías poder atrapar a los usuarios en tu sitio. (Se está analizando en GitHub).

Incluso si no puedes detener o interceptar la navegación, se activará el evento "navigate". Es informativo, por lo que tu código podría, por ejemplo, registrar un evento de Analytics para indicar que un usuario está saliendo de tu sitio.

¿Por qué agregar otro evento a la plataforma?

Un objeto de escucha de eventos "navigate" centraliza el control de los cambios de URL dentro de un SPA. Esta es una propuesta difícil con APIs anteriores. Si alguna vez escribiste el enrutamiento de tu propio SPA con la API de History, es posible que hayas agregado código como el siguiente:

function updatePage(event) {
  event.preventDefault(); // we're handling this link
  window.history.pushState(null, '', event.target.href);
  // TODO: set up page based on new URL
}
const links = [...document.querySelectorAll('a[href]')];
links.forEach(link => link.addEventListener('click', updatePage));

Esto está bien, pero no es exhaustivo. Los vínculos pueden aparecer y desaparecer en tu página, y no son la única forma en que los usuarios pueden navegar por las páginas. Por ejemplo, pueden enviar un formulario o incluso usar un mapa de imágenes. Es posible que tu página las maneje, pero hay una larga cola de posibilidades que se podrían simplificar, algo que logra la nueva API de Navigation.

Además, lo anterior no controla la navegación hacia atrás o hacia adelante. Hay otro evento para eso, "popstate".

En lo personal, la API de History a menudo parece que podría ayudar con estas posibilidades. Sin embargo, en realidad solo tiene dos áreas de superficie: responder si el usuario presiona Atrás o Adelante en su navegador, además de enviar y reemplazar URLs. No tiene una analogía con "navigate", excepto si configuras manualmente objetos de escucha para eventos de clic, por ejemplo, como se demostró anteriormente.

Cómo decidir cómo controlar una navegación

navigateEvent contiene mucha información sobre la navegación que puedes usar para decidir cómo abordar una navegación en particular.

Las propiedades clave son las siguientes:

canIntercept
Si es falso, no puedes interceptar la navegación. No se pueden interceptar las navegaciones entre orígenes ni los recorridos entre documentos.
destination.url
Probablemente, la información más importante que se debe tener en cuenta cuando se controla la navegación.
hashChange
Es verdadero si la navegación es del mismo documento y el hash es la única parte de la URL que es diferente de la URL actual. En los SPA modernos, el hash debe ser para vincular a diferentes partes del documento actual. Por lo tanto, si hashChange es verdadero, es probable que no necesites interceptar esta navegación.
downloadRequest
Si esto es verdadero, la navegación se inició con un vínculo que tiene un atributo download. En la mayoría de los casos, no es necesario que lo interceptes.
formData
Si no es nulo, esta navegación forma parte del envío de un formulario POST. Asegúrate de tener esto en cuenta cuando controles la navegación. Si solo quieres controlar las navegaciones GET, evita interceptar las navegaciones en las que formData no sea nulo. Consulta el ejemplo sobre cómo controlar los envíos de formularios más adelante en el artículo.
navigationType
Es uno de "reload", "push", "replace" o "traverse". Si es "traverse", esta navegación no se puede cancelar a través de preventDefault().

Por ejemplo, la función shouldNotIntercept que se usó en el primer ejemplo podría ser algo como lo siguiente:

function shouldNotIntercept(navigationEvent) {
  return (
    !navigationEvent.canIntercept ||
    // If this is just a hashChange,
    // just let the browser handle scrolling to the content.
    navigationEvent.hashChange ||
    // If this is a download,
    // let the browser perform the download.
    navigationEvent.downloadRequest ||
    // If this is a form submission,
    // let that go to the server.
    navigationEvent.formData
  );
}

Interceptación

Cuando tu código llama a intercept({ handler }) desde su objeto de escucha "navigate", le informa al navegador que ahora está preparando la página para el estado nuevo y actualizado, y que la navegación puede tardar un poco.

El navegador comienza por capturar la posición de desplazamiento del estado actual, de modo que se pueda restablecer de forma opcional más adelante, y luego llama a tu devolución de llamada handler. Si tu handler muestra una promesa (lo que sucede automáticamente con las funciones asíncronas), esa promesa le indica al navegador cuánto tiempo tarda la navegación y si se realiza correctamente.

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

Por lo tanto, esta API presenta un concepto semántico que el navegador comprende: se está produciendo una navegación de SPA en el tiempo, lo que cambia el documento de una URL y un estado anteriores a uno nuevo. Esto tiene varios beneficios potenciales, incluida la accesibilidad: los navegadores pueden mostrar el principio, el final o la posible falla de una navegación. Chrome, por ejemplo, activa su indicador de carga nativo y permite que el usuario interactúe con el botón de detención. (Actualmente, esto no ocurre cuando el usuario navega con los botones Atrás/Adelante, pero se solucionará pronto).

Cuando se intercepten las navegaciones, la URL nueva se aplicará justo antes de que se llame a la devolución de llamada de handler. Si no actualizas el DOM de inmediato, se crea un período en el que se muestra el contenido anterior junto con la URL nueva. Esto afecta elementos como la resolución de URLs relativas cuando se recuperan datos o se cargan subrecursos nuevos.

Se analiza una forma de retrasar el cambio de URL en GitHub, pero, por lo general, se recomienda actualizar la página de inmediato con algún tipo de marcador de posición para el contenido entrante:

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

Esto no solo evita problemas de resolución de URL, sino que también se siente rápido porque respondes al usuario de forma instantánea.

Indicadores de aborto

Dado que puedes realizar trabajo asíncrono en un controlador intercept(), es posible que la navegación se vuelva redundante. Esto ocurre en los siguientes casos:

  • El usuario hace clic en otro vínculo o algún código realiza otra navegación. En este caso, se abandona la navegación anterior en favor de la nueva.
  • El usuario hace clic en el botón "detener" del navegador.

Para abordar cualquiera de estas posibilidades, el evento que se pasa al objeto de escucha "navigate" contiene una propiedad signal, que es un AbortSignal. Para obtener más información, consulta Recuperación abortable.

En resumen, básicamente proporciona un objeto que activa un evento cuando debes detener tu trabajo. En particular, puedes pasar un AbortSignal a cualquier llamada que realices a fetch(), lo que cancelará las solicitudes de red en curso si se anula la navegación. Esto ahorrará el ancho de banda del usuario y rechazará el Promise que devuelve fetch(), lo que evitará que cualquier código posterior realice acciones como actualizar el DOM para mostrar una navegación de página ahora no válida.

Este es el ejemplo anterior, pero con getArticleContent intercalado, que muestra cómo se puede usar AbortSignal con fetch():

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContentURL = new URL(
          '/get-article-content',
          location.href
        );
        articleContentURL.searchParams.set('path', url.pathname);
        const response = await fetch(articleContentURL, {
          signal: navigateEvent.signal,
        });
        const articleContent = await response.json();
        renderArticlePage(articleContent);
      },
    });
  }
});

Manejo del desplazamiento

Cuando intercept() una navegación, el navegador intentará controlar el desplazamiento automáticamente.

Para las navegaciones a una nueva entrada de historial (cuando navigationEvent.navigationType es "push" o "replace"), esto significa intentar desplazarse hasta la parte indicada por el fragmento de URL (el fragmento después de #) o restablecer el desplazamiento hasta la parte superior de la página.

Para las recargas y los recorridos, esto significa restablecer la posición de desplazamiento al lugar donde estaba la última vez que se mostró esta entrada de historial.

De forma predeterminada, esto sucede una vez que se resuelve la promesa que muestra tu handler, pero si tiene sentido desplazarse antes, puedes llamar a navigateEvent.scroll():

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
        navigateEvent.scroll();

        const secondaryContent = await getSecondaryContent(url.pathname);
        addSecondaryContent(secondaryContent);
      },
    });
  }
});

Como alternativa, puedes inhabilitar por completo el control de desplazamiento automático configurando la opción scroll de intercept() en "manual":

navigateEvent.intercept({
  scroll: 'manual',
  async handler() {
    // …
  },
});

Manejo de enfoque

Una vez que se resuelva la promesa que muestra tu handler, el navegador enfocará el primer elemento con el atributo autofocus establecido o el elemento <body> si ningún elemento tiene ese atributo.

Para inhabilitar este comportamiento, configura la opción focusReset de intercept() en "manual":

navigateEvent.intercept({
  focusReset: 'manual',
  async handler() {
    // …
  },
});

Eventos de éxito y error

Cuando se llame a tu controlador intercept(), ocurrirá una de las siguientes dos situaciones:

  • Si se cumple el Promise que se muestra (o no llamaste a intercept()), la API de Navigation activará "navigatesuccess" con un Event.
  • Si se rechaza el Promise que se muestra, la API activará "navigateerror" con un ErrorEvent.

Estos eventos permiten que tu código maneje el éxito o el fracaso de forma centralizada. Por ejemplo, puedes ocultar un indicador de progreso que se mostró anteriormente para indicar que se realizó correctamente, de la siguiente manera:

navigation.addEventListener('navigatesuccess', event => {
  loadingIndicator.hidden = true;
});

También puedes mostrar un mensaje de error en caso de falla:

navigation.addEventListener('navigateerror', event => {
  loadingIndicator.hidden = true; // also hide indicator
  showMessage(`Failed to load page: ${event.message}`);
});

El objeto de escucha de eventos "navigateerror", que recibe un ErrorEvent, es particularmente útil, ya que garantiza que reciba cualquier error de tu código que configure una página nueva. Puedes simplemente await fetch() sabiendo que, si la red no está disponible, el error se redireccionará a "navigateerror".

navigation.currentEntry proporciona acceso a la entrada actual. Es un objeto que describe dónde se encuentra el usuario en este momento. Esta entrada incluye la URL actual, los metadatos que se pueden usar para identificar esta entrada a lo largo del tiempo y el estado proporcionado por el desarrollador.

Los metadatos incluyen key, una propiedad de cadena única de cada entrada que representa la entrada actual y su posición. Esta clave permanece igual, incluso si cambia la URL o el estado de la entrada actual. Sigue en el mismo lugar. Por el contrario, si un usuario presiona Atrás y, luego, vuelve a abrir la misma página, key cambiará, ya que esta entrada nueva crea un espacio nuevo.

Para un desarrollador, key es útil porque la API de Navigation te permite dirigir al usuario directamente a una entrada con una clave coincidente. Puedes mantenerlo, incluso en los estados de otras entradas, para saltar fácilmente entre páginas.

// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const {key} = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);

// Navigate away, but the button will always work.
await navigation.navigate('/another_url').finished;

Estado

La API de Navigation muestra un concepto de "estado", que es la información que proporciona el desarrollador y que se almacena de forma persistente en la entrada de historial actual, pero que el usuario no puede ver directamente. Esto es muy similar a history.state en la API de History, pero con mejoras.

En la API de Navigation, puedes llamar al método .getState() de la entrada actual (o cualquier entrada) para mostrar una copia de su estado:

console.log(navigation.currentEntry.getState());

De forma predeterminada, será undefined.

Estado de configuración

Aunque los objetos de estado se pueden mutar, esos cambios no se guardan con la entrada de historial, por lo que sucede lo siguiente:

const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// But:
console.info(navigation.currentEntry.getState().count); // will still be 1

La forma correcta de establecer el estado es durante la navegación de la secuencia de comandos:

navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});

Donde newState puede ser cualquier objeto clonable.

Si deseas actualizar el estado de la entrada actual, es mejor realizar una navegación que reemplace la entrada actual:

navigation.navigate(location.href, {state: newState, history: 'replace'});

Luego, tu objeto de escucha de eventos "navigate" puede detectar este cambio a través de navigateEvent.destination:

navigation.addEventListener('navigate', navigateEvent => {
  console.log(navigateEvent.destination.getState());
});

Actualiza el estado de forma síncrona

Por lo general, es mejor actualizar el estado de forma asíncrona a través de navigation.reload({state: newState}), de modo que tu objeto de escucha "navigate" pueda aplicar ese estado. Sin embargo, a veces, el cambio de estado ya se aplicó por completo cuando tu código lo detecta, por ejemplo, cuando el usuario activa o desactiva un elemento <details> o cambia el estado de una entrada de formulario. En estos casos, te recomendamos que actualices el estado para que estos cambios se conserven a través de las recargas y los recorridos. Esto es posible con updateCurrentEntry():

navigation.updateCurrentEntry({state: newState});

También hay un evento para conocer este cambio:

navigation.addEventListener('currententrychange', () => {
  console.log(navigation.currentEntry.getState());
});

Sin embargo, si reaccionas a los cambios de estado en "currententrychange", es posible que estés dividiendo o incluso duplicando tu código de control de estado entre el evento "navigate" y el evento "currententrychange", mientras que navigation.reload({state: newState}) te permitiría controlarlo en un solo lugar.

Parámetros de estado en comparación con los de URL

Dado que el estado puede ser un objeto estructurado, es tentador usarlo para todo el estado de tu aplicación. Sin embargo, en muchos casos, es mejor almacenar ese estado en la URL.

Si esperas que el estado se retenga cuando el usuario comparte la URL con otro usuario, almacénala en la URL. De lo contrario, el objeto de estado es la mejor opción.

Acceso a todas las entradas

Sin embargo, la "entrada actual" no es todo. La API también proporciona una forma de acceder a toda la lista de entradas por las que navegó un usuario mientras usaba tu sitio a través de su llamada a navigation.entries(), que muestra un array de instantáneas de entradas. Esto se podría usar, por ejemplo, para mostrar una IU diferente según la forma en que el usuario navegó a una página determinada o simplemente para volver a ver las URLs anteriores o sus estados. Esto es imposible con la API de History actual.

También puedes escuchar un evento "dispose" en NavigationHistoryEntry individuales, que se activa cuando la entrada ya no forma parte del historial del navegador. Esto puede ocurrir como parte de la limpieza general, pero también puede ocurrir durante la navegación. Por ejemplo, si retrocedes 10 lugares y, luego, navegas hacia adelante, se descartarán esas 10 entradas del historial.

Ejemplos

El evento "navigate" se activa para todos los tipos de navegación, como se mencionó anteriormente. (En realidad, hay un apéndice largo en la especificación de todos los tipos posibles).

Si bien para muchos sitios el caso más común será cuando el usuario haga clic en un <a href="...">, hay dos tipos de navegación notables y más complejos que vale la pena abordar.

Navegación programática

La primera es la navegación programática, en la que una llamada de método dentro del código del cliente causa la navegación.

Puedes llamar a navigation.navigate('/another_page') desde cualquier parte de tu código para provocar una navegación. El objeto de escucha de eventos centralizado registrado en el objeto de escucha "navigate" se encargará de esto, y se llamará de forma síncrona a tu objeto de escucha centralizado.

Se trata de una agregación mejorada de métodos más antiguos, como location.assign() y sus amigos, además de los métodos pushState() y replaceState() de la API de History.

El método navigation.navigate() muestra un objeto que contiene dos instancias de Promise en { committed, finished }. Esto permite que el llamador espere hasta que la transición esté "confirmada" (la URL visible cambió y hay un NavigationHistoryEntry nuevo disponible) o "finalizada" (todas las promesas que devuelve intercept({ handler }) están completas o rechazadas debido a una falla o a que otra navegación las anula).

El método navigate también tiene un objeto de opciones, en el que puedes establecer lo siguiente:

  • state: Es el estado de la nueva entrada de historial, como está disponible a través del método .getState() en NavigationHistoryEntry.
  • history: que se puede establecer en "replace" para reemplazar la entrada de historial actual.
  • info: Es un objeto que se pasa al evento de navegación a través de navigateEvent.info.

En particular, info podría ser útil para, por ejemplo, denotar una animación en particular que haga que aparezca la siguiente página. (La alternativa podría ser establecer una variable global o incluirla como parte del #hash. Ambas opciones son un poco incómodas). En particular, este info no se volverá a reproducir si un usuario más adelante realiza una navegación, p.ej., a través de los botones Atrás y Adelante. De hecho, siempre será undefined en esos casos.

Demostración de la apertura desde la izquierda o la derecha

navigation también tiene varios otros métodos de navegación, todos los cuales muestran un objeto que contiene { committed, finished }. Ya mencioné traverseTo() (que acepta un key que denota una entrada específica en el historial del usuario) y navigate(). También incluye back(), forward() y reload(). El objeto de escucha de eventos "navigate" centralizado controla todos estos métodos, al igual que navigate().

Envíos de formularios

En segundo lugar, el envío de <form> HTML a través de POST es un tipo especial de navegación, y la API de Navigation puede interceptarlo. Si bien incluye una carga útil adicional, el objeto de escucha "navigate" sigue controlando la navegación de forma centralizada.

Para detectar el envío de formularios, busca la propiedad formData en NavigateEvent. Este es un ejemplo que simplemente convierte cualquier envío de formulario en uno que permanece en la página actual a través de fetch():

navigation.addEventListener('navigate', navigateEvent => {
  if (navigateEvent.formData && navigateEvent.canIntercept) {
    // User submitted a POST form to a same-domain URL
    // (If canIntercept is false, the event is just informative:
    // you can't intercept this request, although you could
    // likely still call .preventDefault() to stop it completely).

    navigateEvent.intercept({
      // Since we don't update the DOM in this navigation,
      // don't allow focus or scrolling to reset:
      focusReset: 'manual',
      scroll: 'manual',
      handler() {
        await fetch(navigateEvent.destination.url, {
          method: 'POST',
          body: navigateEvent.formData,
        });
        // You could navigate again with {history: 'replace'} to change the URL here,
        // which might indicate "done"
      },
    });
  }
});

¿Qué información falta?

A pesar de la naturaleza centralizada del objeto de escucha de eventos "navigate", la especificación actual de la API de Navigation no activa "navigate" en la primera carga de una página. Y para los sitios que usan renderización del servidor (SSR) para todos los estados, esto podría estar bien, ya que tu servidor podría mostrar el estado inicial correcto, que es la forma más rápida de entregar contenido a tus usuarios. Sin embargo, es posible que los sitios que aprovechan el código del cliente para crear sus páginas deban crear una función adicional para inicializarlas.

Otra decisión de diseño intencional de la API de Navigation es que solo opera dentro de un solo marco, es decir, la página de nivel superior o un solo <iframe> específico. Esto tiene varias implicaciones interesantes que se documentan con más detalle en la especificación, pero, en la práctica, reducirá la confusión de los desarrolladores. La API de History anterior tiene varios casos extremos confusos, como la compatibilidad con marcos, y la API de Navigation rediseñada controla estos casos extremos desde el principio.

Por último, aún no hay consenso sobre la modificación o reorganización de forma programática de la lista de entradas por las que navegó el usuario. Actualmente, se está analizando, pero una opción podría ser permitir solo las eliminaciones: ya sea de las entradas históricas o de "todas las entradas futuras". La última opción permitiría el estado temporal. Por ejemplo, como desarrollador, podría hacer lo siguiente:

  • navegar a una URL o un estado nuevos para hacerle una pregunta al usuario
  • Permite que el usuario complete su trabajo (o volver atrás).
  • quitar una entrada del historial cuando se completa una tarea

Esto podría ser perfecto para los modales o intersticiales temporales: el usuario puede usar el gesto Atrás para salir de la URL nueva, pero no puede ir accidentalmente Adelante para volver a abrirla (porque se quitó la entrada). Esto no es posible con la API de History actual.

Prueba la API de Navigation

La API de Navigation está disponible en Chrome 102 sin marcas. También puedes probar una demostración de Domenic Denicola.

Si bien la API de History clásica parece ser sencilla, no está muy bien definida y tiene una gran cantidad de problemas relacionados con casos extremos y la forma en que se implementó de manera diferente en los navegadores. Esperamos que consideres enviar comentarios sobre la nueva API de Navigation.

Referencias

Agradecimientos

Gracias a Thomas Steiner, Domenic Denicola y Nate Chapin por revisar esta publicación.