wandres.dev
STACKSTATE · navegación en pila

Deep linking: construir la pila entera desde una URL al arrancar

El pago de haber modelado la navegación como datos se cobra aquí. Esta lección convierte el enlace profundo en lo que debería haber sido siempre: una función pura de URL a `StackState`, probada sin interfaz, aplicada como estado inicial del store en el arranque o como sustitución de la pila cuando el enlace llega en caliente. Incluye la restauración de sesión por serialización, la degradación elegante ante rutas inválidas, y los dos matices prácticos —animación y datos no cargados— que separan una demostración de una implementación de producción.

⏱ 20 min

El enlace profundo es la prueba de fuego de cualquier arquitectura de navegación, y es donde el modelo imperativo enseña sin remedio su costura. Allí, abrir la aplicación en una pantalla honda significa reproducir la secuencia de empujes que habría hecho un usuario: empuja el catálogo, espera a que cargue, empuja el producto, espera, empuja la reseña. Es una coreografía dependiente del reloj, plagada de comprobaciones defensivas, imposible de probar sin simulador y con una tendencia notoria a aterrizar en sitios distintos según lo rápido que responda la red. Con la navegación modelada como datos, el enlace profundo pierde toda esa complejidad de golpe, porque deja de ser una secuencia que hay que ejecutar y pasa a ser un valor que hay que construir. La aplicación no viaja hasta el destino: nace allí.

🎯 Al terminar esta lección sabrás
  • Escribir la traducción de una URL a una pila como función pura, probable sin interfaz ni simulador.
  • Arrancar el store con la pila ya poblada para que la aplicación abra directamente en la pantalla profunda.
  • Atender enlaces que llegan con la aplicación en marcha sustituyendo la pila desde el reducer.
  • Serializar y restaurar la navegación de una sesión, y degradar con elegancia ante rutas inválidas.

La ruta como función pura

El primer paso no toca ni el store ni la vista: es una función que recibe una URL y devuelve una colección de estados. Al no depender de nada más, se prueba con una tabla de casos y se corrige sin abrir la aplicación.

extension Tienda.State {
  static func camino(desde url: URL) -> StackState<Tienda.Camino.State> {
    guard let partes = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
      return StackState()
    }
    switch partes.path.split(separator: "/").map(String.init) {
    case ["producto", let pid]:
      return StackState([.producto(Producto.State(id: pid))])
    case ["producto", let pid, "resena", let rid]:
      return StackState([
        .producto(Producto.State(id: pid)),
        .resena(Resena.State(id: rid)),
      ])
    default:
      return StackState()
    }
  }
}

Fíjate en la degradación: una ruta desconocida devuelve una pila vacía, es decir, la raíz. No hay excepción, ni pantalla de error, ni estado a medio construir; el peor caso posible de un enlace corrupto es que el usuario abra la aplicación por su portada. Esa robustez no es mérito del código sino del tipo de retorno: cuando la respuesta obligatoria es una pila válida, no existe la posibilidad de devolver media navegación.

💡
Prueba la traducción antes de tener pantallas

Esta función se afirma con una prueba unitaria corriente que compara dos valores, sin TestStore, sin dependencias y sin vista. Escríbela primero y tendrás cubierto el enlace profundo entero antes de que exista una sola pantalla, porque todo lo que viene después —arrancar con ese valor, sustituirlo en caliente— es mecánico. La parte que históricamente concentraba los fallos se ha convertido en la parte más barata de verificar.

Arrancar con la pila ya construida

Con la traducción resuelta, el arranque consiste en pasarle al store un estado inicial que ya contiene el camino. La aplicación se dibuja una sola vez, directamente en su forma final.

@main
struct TiendaApp: App {
  let store: StoreOf<Tienda>

  init() {
    let camino = URL.enlaceDeLanzamiento.map(Tienda.State.camino(desde:)) ?? StackState()
    self.store = Store(initialState: Tienda.State(camino: camino)) {
      Tienda()
    }
  }

  var body: some Scene {
    WindowGroup {
      TiendaView(store: store)
        .onOpenURL { url in store.send(.enlaceRecibido(url)) }
    }
  }
}

No hay espera, ni encadenamiento, ni comprobación de que la pantalla anterior terminó de presentarse. El NavigationStack recibe un camino de dos elementos en su primer dibujo y monta las dos pantallas de una vez, cada una con su store derivado y su reducer corriendo. Y como cada destino se construyó con su State completo, ninguna necesita descubrir quién es a partir de un identificador suelto.

Vía de entrada Momento Operación sobre la pila
Enlace de lanzamiento al crear el store estado inicial con el camino poblado
Enlace universal | notificación en caliente aplicación en marcha sustitución desde el reducer
Restauración de sesión al arrancar deserializar el camino guardado

En caliente, y de vuelta desde el disco

Cuando el enlace llega con la aplicación funcionando, la operación es la misma con otro nombre: en vez de construir el estado inicial, se reemplaza el campo.

case let .enlaceRecibido(url):
  state.camino = Tienda.State.camino(desde: url)
  return .none

Una línea sustituye la navegación entera, venga el usuario de donde venga. Lo que había en la pila se descarta, y con ello se cancelan automáticamente todos los efectos en vuelo de las pantallas retiradas: la garantía de forEach cubre también las sustituciones masivas, no solo los retrocesos de uno en uno.

La restauración de sesión es literalmente el mismo mecanismo con otro origen. Si los estados de los destinos son codificables, StackState lo es, y guardar dónde estaba el usuario se reduce a serializar un valor al salir y deserializarlo al volver.

// Al pasar a segundo plano
try? Data(JSONEncoder().encode(store.camino)).write(to: .sesion)

// Al arrancar
let camino = (try? JSONDecoder().decode(
  StackState<Tienda.Camino.State>.self, from: Data(contentsOf: .sesion)
)) ?? StackState()
🔗

URL a valor

Traducir un enlace es una función pura que devuelve una pila, probable sin interfaz.

🚀

Nacer en destino

El estado inicial ya contiene el camino: la aplicación se dibuja una vez, en su forma final.

🔁

Sustitución en caliente

Un enlace recibido en marcha reemplaza el campo y cancela los efectos de lo que desaparece.

💾

Sesión serializable

Si los destinos son codificables, la navegación entera se guarda y se recupera como un dato.

flowchart LR
U[URL entrante] --> F[Funcion pura de traduccion]
F --> P[StackState construido]
P --> I[Estado inicial del store]
P --> H[Sustitucion en caliente]
D[Sesion en disco] --> P
I --> V[La vista monta la pila entera]
H --> V
style F fill:#89b4fa,color:#11111b
style V fill:#a6e3a1,color:#11111b

Quedan dos matices que separan la demostración de la producción. El primero es la animación: al asignar varias pantallas de una vez con la aplicación en marcha, SwiftUI intentará animar la diferencia completa, lo que produce una transición atropellada; si el salto es largo, conviene aplicarlo sin animación y dejar visible solo el resultado. El segundo es la carga de datos: una pantalla que solo pide sus datos al aparecer los pedirá también al llegar por enlace profundo, así que si te importa que no parpadee, construye su State con lo que ya sabes en lugar de dejarla vacía a la espera.

El enlace profundo no es una funcionalidad: es el examen que revela si tu navegación era un dato

Vale la pena reconocer que el enlace profundo funciona como diagnóstico, no como característica. Cualquier aplicación puede añadirlo a fuerza de voluntad, y el modo en que le cuesta hacerlo mide exactamente cuánta de su navegación vivía fuera de su estado. Si abrir una pantalla honda obliga a encadenar empujes con esperas, es que la navegación era una secuencia de órdenes cuyo efecto residía en el sistema de vistas. Si obliga a que cada pantalla sepa reconstruirse desde un identificador, es que el estado de los destinos estaba disperso en lugar de ser propiedad de quien los presenta. Y si el resultado depende de lo rápido que responda la red, es que el orden de las pantallas era una consecuencia del reloj y no una propiedad declarada. Las tres patologías son la misma vista desde ángulos distintos, y las tres desaparecen no porque TCA traiga una herramienta de enlaces profundos —no la trae, y todo lo de esta lección son cuatro líneas de Swift corriente—, sino porque la pila ya era un valor antes de que apareciera la primera URL. Lo verdaderamente notable es lo que ese mismo hecho concede gratis, sin una línea adicional: la restauración de sesión es serializar ese valor, el destino de una notificación es asignarlo, una previsualización de la quinta pantalla es construirlo a mano, una prueba de un flujo entero es afirmarlo, y un informe de fallo puede adjuntar la navegación exacta del usuario porque cabe en un JSON. Cinco capacidades que en el modelo imperativo son cinco proyectos independientes con sus propias fuentes de error resultan aquí ser la misma operación —leer o escribir una colección— vista con distinta iluminación. Ese es el rendimiento acumulado de la decisión que se tomó en el Nivel 3 y que este nivel ha desplegado hasta el final: cuando decides que estar en una pantalla es tener un valor en vez de haber ejecutado un comando, dejas de programar la navegación y pasas a describirla.

⚔️ Abre tu aplicación en la pantalla más honda que tenga
  1. Escribe la función pura que traduce tus URLs a una pila y cúbrela con una prueba unitaria que incluya, como mínimo, una ruta profunda válida, una parcial y una corrupta.
  2. Comprueba que la ruta corrupta devuelve la raíz y no un estado a medias. Si tu función puede fallar de otra forma, cambia el tipo de retorno hasta que no pueda.
  3. Cablea el arranque para que el store nazca con ese camino y verifica que la aplicación abre directamente en la pantalla honda, sin transiciones intermedias visibles.
  4. Añade la vía en caliente con una sola línea que sustituya el campo, y confirma que los efectos en vuelo de las pantallas descartadas se detienen sin que escribas ninguna limpieza.
  5. Serializa la pila al pasar a segundo plano y restáurala al volver. Después mata la aplicación desde el conmutador y ábrela de nuevo: si aparece donde la dejaste, tu navegación era un dato desde el principio.