Angular 22.2: Mejoras que SI vas a usar
Angular 22.2 fue lanzada 23 de septiembre con dos features grandes en developer preview, @boundary y router resources, más algunas cosas que sí cambian tu día a día. En este post vas a ver cómo funcionan las dos, con ejemplos listos para copiar, y las 8 novedades restantes en formato rápido. Sin relleno.
Photo by Clément Hélardot on Unsplash
@boundary: error boundaries nativos, por fin
Si un componente lanza un error durante su render o su change detection, Angular deja de renderizar el sub-árbol completo. Te queda una página a medias, un error en consola y un usuario mirando un hueco.
"Angular v22.2 introduced a (developer preview) mechanism to catch errors in a component and display a fallback UI instead of the error: the @boundary syntax." - Source: Ninja Squad
Imagina el caso del widget de pagos. El componente PaymentWidget es de terceros, no controlas su código, y lanza un PaymentError cuando algo falla. En Checkout puedes aislarlo así:
<!-- checkout.html -->
<h2>Checkout</h2>
@boundary {
<app-payment-widget [amount]="total()" />
} @error (when isNetworkError($error)) {
<p class="warning">No pudimos hablar con el banco. Revisa tu conexión.</p>
<button (click)="$reset()">Reintentar pago</button>
} @error (when isCardDeclined($error)) {
<p class="error">Tu banco rechazó el pago: {{ $error.message }}. Prueba con otra tarjeta.</p>
} @error {
<p class="error">Algo salió mal en el pago. Nuestro equipo ya está en ello.</p>
}
// checkout.ts
import { Component, signal } from '@angular/core';
import { PaymentWidget } from '@acme/payment-widget';
export class PaymentError extends Error {
constructor(
public readonly code: 'NETWORK' | 'CARD_DECLINED',
message: string,
) {
super(message);
}
}
export const isNetworkError = (e: unknown) =>
e instanceof PaymentError && e.code === 'NETWORK';
export const isCardDeclined = (e: unknown) =>
e instanceof PaymentError && e.code === 'CARD_DECLINED';
@Component({
selector: 'app-checkout',
imports: [PaymentWidget],
templateUrl: './checkout.html',
})
export class Checkout {
readonly total = signal(49.9);
}
Nota que el último @error no lleva when: es el catch-all para lo que no encaje en los casos anteriores. Dentro de cualquier bloque @error tienes acceso a $error (el error capturado; puedes renombrarlo con @error (let err)) y a $reset(), que reintenta el render del componente que explotó. Ese botón de "Reintentar pago" funciona sin escribir ni una línea de lógica de reintento.
Si prefieres crear componentes programáticamente con createComponent, la misma protección existe vía onError:
// dynamic-host.ts
viewContainer.createComponent(PaymentWidget, {
onError: (error, details) => {
// ErrorDetails incluye el boundary, la función $reset y el tipo/instancia
// del componente o directiva donde ocurrió el error
this.logger.error(error, { source: details.declarationType });
},
});
También puedes colgar onViewError en tu ErrorHandler global para capturar estos errores en un solo sitio. - Source: angular/angular PR #70463
Mi consejo de uso: un boundary por sección independiente (checkout, mapa, dashboard de métricas), no uno global envolviendo la app. Si ocultas todo detrás de un fallback, un error silencioso es peor que una página rota visible. Aún está en developer preview, así que no lo cases en una librería interna todavía.
Router resources: resolvers con signals y sin cascadas
Los resolvers cargan datos antes de activar una ruta. En la teoría, perfectos. En la práctica, tres problemas: bloquean la navegación, los resolvers de rutas padre e hijo corren en secuencia (cascade), y no se integran con signals ni con la API de resource. En cinco años de proyectos he escrito tres resolvers: dos para un tutorial y uno que alguien copió de ese tutorial.
"Router resources serve the same purpose as resolvers, but integrate with signals and resources. Resources across all matched routes are loaded in parallel, avoiding parent-child waterfalls when several resources are needed." - Source: Ninja Squad
La idea en una línea: declaras la carga de datos en la configuración de la ruta, como harías con un resolver, pero lo que devuelves son resources de verdad. Primero, lo habilitas:
// app.config.ts
provideRouter(routes, withComponentInputBinding(), withRouterResources());
Y este es un caso real de una ficha de producto: el producto bloquea la navegación (sin él no hay nada que renderizar), pero las reviews no tienen por qué retrasar la entrada:
// app.routes.ts
import { computed } from '@angular/core';
import { Routes } from '@angular/router';
import { httpResource } from '@angular/common/http';
import { nonBlocking } from '@angular/router';
export const routes: Routes = [
{
path: 'products/:id',
component: ProductDetail,
resources: (context) => {
// context expone params y queryParams como signals
const productId = computed(() => context.params()['id']);
const tab = computed(() => context.queryParams()['tab']);
// blocking: la navegación espera a que cargue
const product = httpResource<Product>(() => `/api/products/${productId()}`);
// nonBlocking: la navegación sigue y el resource llega en carga
const reviews = nonBlocking(
httpResource<Review[]>(() => `/api/products/${productId()}/reviews?tab=${tab()}`),
);
return { product, reviews };
},
},
];
// product-detail.ts
import { Component, input } from '@angular/core';
import { Resource } from '@angular/core';
@Component({ selector: 'app-product-detail', templateUrl: './product-detail.html' })
export class ProductDetail {
// blocking: llega resuelto, nunca en estado loading
readonly product = input.required<Product>();
// nonBlocking: llega el resource completo y tú manejas los estados
readonly reviews = input.required<Resource<Review[] | undefined>>();
}
<!-- product-detail.html -->
<h2>{{ product().name }}</h2>
<p>{{ product().price | currency }}</p>
@if (reviews().hasValue()) {
<app-review-list [items]="reviews().value()!" />
} @else if (reviews().isLoading()) {
<app-skeleton rows="5" />
}
Pero hay truco con los computed() intermedios. Si el resource leyera context.queryParams() directo, se recargaría con cualquier cambio de query param, aunque fuera uno que no pinta nada en la petición (piensa en ?sort=price¤cy=EUR cuando tu recurso solo depende de tab). El computed aísla la dependencia: solo se recalcula cuando cambia lo que le importa.
Dos comportamientos que conviene tener en el radar:
- Blocking: mientras navega, el router "congela" el resource exponiendo el último snapshot válido (el carrito no se vacía mientras procesa el pago). La URL solo se actualiza si la navegación triunfa; si el resource falla, la navegación se cancela con un
NavigationError, que puedes manejar conwithNavigationErrorHandler. - NonBlocking: la URL se actualiza al completar la navegación sin esperar el resource, y mientras carga el valor es
undefinedaunque hubiera uno anterior. Por eso el componente debe pintar un loading state en vez de asumir el dato viejo.
También los puedes leer sin input binding vía ActivatedRoute.resources. Si vienes de resolvers, la guía del Angular Router que escribí hace un tiempo te sirve de mapa para migrar caso a caso. Y como con @boundary: developer preview, API sujeta a cambios.
throw new RedirectCommand(): guards que redirigen sin return
Angular 18 introdujo RedirectCommand para devolverlo desde un guard. En 22.2 también puedes lanzarlo, y el router lo captura igual que un return. Mismo efecto, con la ergonomía del redirect() de Next.js o SvelteKit:
// admin.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, RedirectCommand, Router } from '@angular/router';
import { SessionService } from './session.service';
export const adminOnly: CanActivateFn = () => {
const session = inject(SessionService);
const router = inject(Router);
if (!session.isLoggedIn()) {
throw new RedirectCommand(router.parseUrl('/session-expired'));
}
if (session.role() !== 'admin') {
throw new RedirectCommand(router.parseUrl('/forbidden'));
}
return true;
};
La ganancia real aparece cuando el fallo ocurre en medio de una función (por ejemplo, dentro de un resource que recibe un 403): lanzas y cortas el flujo en vez de enredarte con condiciones de retorno. - Source: Ninja Squad
8 mejoras más, en formato rápido
1. withAutoCleanupInjectors estabilizado. El feature experimental de v21.1 que destruye injectors automáticamente cuando su contexto muere se estabilizó y cambió de nombre: pasó de withExperimentalAutoCleanupInjectors a withAutoCleanupInjectors. - Source: Ninja Squad
2. Diagnóstico strictUnclaimedEventNames (NG8030). Hasta hoy el compiler no decía nada si escribías (productClik)="onProductClick()" y ningún directivo aplicado al elemento emitía ese evento. Con el flag activado, el typo muere en compilación. Solo aplica a nombres camelCase; los eventos con guiones (my-event) quedan exentos. - Source: Ninja Squad
3. Warning NG0318 en bindings de estilos. En desarrollo, un [style.width]="isWide" (un booleano donde va un string) ahora te avisa en consola: "Expected a string, number, SafeValue, null, or undefined, but received boolean". Antes era un bug silencioso; la vista simplemente no se veía como esperabas. - Source: angular.dev - NG0318
4. Devtools con Change Detection analyzer. Activa el modo experimental en settings y el árbol de componentes muestra cuántas veces corrió el change detection de cada componente y cuánto tardó la última pasada, con fondo rojo si pasa de 16.6ms (el presupuesto de los 60fps). Además, el panel de signals estrena botón "Watch signal" (loggea cada cambio en consola) y breakpoints sobre signals en Chrome. Es la idea de React Scan, pero sin instalar nada. - Source: Ninja Squad
5. Builds más rápidos. El CLI refactorizó el pipeline: el type-checking ya no bloquea el bundling de esbuild; ambos corren en paralelo y el tiempo total se acerca al de la tarea más lenta. En un proyecto grande de prueba, el build pasó de 56s a 48s. Menos visible en ng serve, pero gratis es gratis. - Source: Ninja Squad
6. browser-stats.json y server-stats.json. Si tu app tiene SSR, ng build --stats-json genera ahora dos archivos separados en lugar de mezclar todo en stats.json. Analizar el peso del bundle del cliente con esbuild-visualizer sin ruido del servidor es más cómodo. - Source: Ninja Squad
7. Vitest 5. El CLI saltó a Vitest v5 y genera la config como vitest-base.config.mts (para que Node la trate como ESM). La v5 trae mejoras de rendimiento y algunos breaking changes; revisa la guía de migración antes de actualizar. - Source: Vitest - Migration Guide
8. Detalles finos. Las plantillas ya pueden acceder a propiedades privadas del componente. Las view queries pueden leer un Injector (viewChild('ref', { read: Injector })) para sacar servicios de providers de componentes hijos. En Signal Forms, hidden() ya se puede llamar sin when para campos ocultos permanentemente. Y por el lado de AI: declareExperimentalWebMcpTool acepta annotations (readOnlyHint, untrustedContentHint, consequentialHint). El MCP del CLI estrena flag --root para monorepos, repetible si necesitas dar acceso a varios directorios. Si llegaste tarde al ciclo v22, en el post de Angular 22.0 explico WebMCP desde cero. - Source: Ninja Squad
Lo que haría en producción esta semana
- Añade un
@boundarypor sección, no uno global. Checkout, mapa, widgets de terceros: cada uno con su fallback y su$reset(). Un catch-all que oculta toda la app convierte un bug visible en un bug invisible. - Empieza con
nonBlockingy bloquea solo lo esencial. Regla simple: si el dato aparece en el primer render útil, blocking; si es contenido secundario (reviews, recomendados, comentarios), nonBlocking con su skeleton. - Activa
strictUnclaimedEventNameshoy. Los typos en outputs no dan error de compilación y se descubren en producción cuando el botón no hace nada. Cinco minutos de config, cero sustos. - Pasa una tarde con el Change Detection analyzer. Ordena componentes por tiempo y mira quién supera 16.6ms. En apps zoneless con muchos signals, verás candidatos a refactor en minutos.
- Cuidado en producciones críticas con las dev previews.
@boundaryy router resources pueden cambiar de API antes de estabilizar. Para features de cliente de alto riesgo, espera la versión estable o aíslalos detrás de un wrapper.
La v22.3 llega en noviembre. Mientras tanto, ¿ya probaste @boundary o los router resources en tus proyectos? Cuéntame en los comentarios.