From 9c3a6898e7c50ded6d0b8572ac2346c660b5ad87 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:54:17 +0000 Subject: [PATCH] fix(@angular/ssr): ensure server-side navigation triggers a redirect When a navigation occurs on the server-side, such as using `router.navigate`, and the final URL is different from the initial URL that was requested, the server should respond with a redirect. Previously, the initial URL was being read from `router.lastSuccessfulNavigation.initialUrl`, which could be incorrect in scenarios involving server-side navigations, causing the comparison with the final URL to fail and preventing the redirect. This change ensures that the initial URL requested by the browser is used for the comparison, correctly triggering a redirect when necessary. Closes #31482 --- packages/angular/ssr/src/utils/ng.ts | 12 +++-- packages/angular/ssr/test/app-engine_spec.ts | 12 ----- packages/angular/ssr/test/app_spec.ts | 49 +++++++++++++++++++- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index fe8148727425..34b2e8e36024 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -96,24 +96,26 @@ export async function renderAngular( applicationRef = await bootstrap({ platformRef }); } + const envInjector = applicationRef.injector; + const router = envInjector.get(Router); + const initialUrl = router.currentNavigation()?.initialUrl.toString(); + // Block until application is stable. await applicationRef.whenStable(); // TODO(alanagius): Find a way to avoid rendering here especially for redirects as any output will be discarded. - const envInjector = applicationRef.injector; const routerIsProvided = !!envInjector.get(ActivatedRoute, null); - const router = envInjector.get(Router); const lastSuccessfulNavigation = router.lastSuccessfulNavigation(); if (!routerIsProvided) { hasNavigationError = false; - } else if (lastSuccessfulNavigation?.finalUrl) { + } else if (lastSuccessfulNavigation?.finalUrl && initialUrl !== null) { hasNavigationError = false; - const { finalUrl, initialUrl } = lastSuccessfulNavigation; + const { finalUrl } = lastSuccessfulNavigation; const finalUrlStringified = finalUrl.toString(); - if (initialUrl.toString() !== finalUrlStringified) { + if (initialUrl !== finalUrlStringified) { const baseHref = envInjector.get(APP_BASE_HREF, null, { optional: true }) ?? envInjector.get(PlatformLocation).getBaseHrefFromDOM(); diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 90c8d9ae10cc..966edadce843 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -19,18 +19,6 @@ import { RenderMode } from '../src/routes/route-config'; import { setAngularAppTestingManifest } from './testing-utils'; function createEntryPoint(locale: string) { - @Component({ - selector: `app-ssr-${locale}`, - template: `SSR works ${locale.toUpperCase()}`, - }) - class SSRComponent {} - - @Component({ - selector: `app-ssg-${locale}`, - template: `SSG works ${locale.toUpperCase()}`, - }) - class SSGComponent {} - return async () => { @Component({ selector: `app-home-${locale}`, diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index e5ccddbfddbf..f85d4700329f 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -11,7 +11,8 @@ import '@angular/compiler'; /* eslint-enable import/no-unassigned-import */ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; import { AngularServerApp } from '../src/app'; import { RenderMode } from '../src/routes/route-config'; import { setAngularAppTestingManifest } from './testing-utils'; @@ -26,6 +27,31 @@ describe('AngularServerApp', () => { }) class HomeComponent {} + @Component({ + selector: 'app-redirect', + }) + class RedirectComponent { + constructor() { + void inject(Router).navigate([], { + queryParams: { filter: 'test' }, + }); + } + } + + const queryParamAdderGuard: CanActivateFn = (_route, state) => { + const urlTree = inject(Router).parseUrl(state.url); + + if (urlTree.queryParamMap.has('filter')) { + return true; + } + + urlTree.queryParams = { + filter: 'test', + }; + + return urlTree; + }; + setAngularAppTestingManifest( [ { path: 'home', component: HomeComponent }, @@ -33,7 +59,14 @@ describe('AngularServerApp', () => { { path: 'home-ssg', component: HomeComponent }, { path: 'page-with-headers', component: HomeComponent }, { path: 'page-with-status', component: HomeComponent }, + { path: 'redirect', redirectTo: 'home' }, + { path: 'redirect-via-navigate', component: RedirectComponent }, + { + path: 'redirect-via-guard', + canActivate: [queryParamAdderGuard], + component: HomeComponent, + }, { path: 'redirect/relative', redirectTo: 'home' }, { path: 'redirect/:param/relative', redirectTo: 'home' }, { path: 'redirect/absolute', redirectTo: '/home' }, @@ -259,11 +292,23 @@ describe('AngularServerApp', () => { }); describe('SSR pages', () => { - it('returns a 302 status and redirects to the correct location when redirectTo is a function', async () => { + it('returns a 302 status and redirects to the correct location when `redirectTo` is a function', async () => { const response = await app.handle(new Request('http://localhost/redirect-to-function')); expect(response?.headers.get('location')).toBe('/home'); expect(response?.status).toBe(302); }); + + it('returns a 302 status and redirects to the correct location when `router.navigate` is used', async () => { + const response = await app.handle(new Request('http://localhost/redirect-via-navigate')); + expect(response?.headers.get('location')).toBe('/redirect-via-navigate?filter=test'); + expect(response?.status).toBe(302); + }); + + it('returns a 302 status and redirects to the correct location when `urlTree` is updated in a guard', async () => { + const response = await app.handle(new Request('http://localhost/redirect-via-guard')); + expect(response?.headers.get('location')).toBe('/redirect-via-guard?filter=test'); + expect(response?.status).toBe(302); + }); }); }); });