From 49374952edaed04cba0cc1e3a93b95a6f0aa9da6 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 5 Nov 2024 09:26:42 +0000 Subject: [PATCH] fix(@angular/build): handle `APP_BASE_HREF` correctly in prerendered routes This commit resolves path stripping issues when `APP_BASE_HREF` does not align with the expected value. Closes #28775 --- .../src/utils/server-rendering/prerender.ts | 17 ++- ...s-output-mode-static-i18n_APP_BASE_HREF.ts | 106 ++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 819faa63cc9f..95f3a4fd8e74 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -219,10 +219,11 @@ async function renderPages( const baseHrefWithLeadingSlash = addLeadingSlash(baseHref); for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) { - // Remove base href from file output path. - const routeWithoutBaseHref = addLeadingSlash( - route.slice(baseHrefWithLeadingSlash.length - 1), - ); + // Remove the base href from the file output path. + const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash) + ? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length - 1)) + : route; + const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); if (typeof redirectTo === 'string') { @@ -336,11 +337,15 @@ async function getAllRoutes( } function addLeadingSlash(value: string): string { - return value.charAt(0) === '/' ? value : '/' + value; + return value[0] === '/' ? value : '/' + value; +} + +function addTrailingSlash(url: string): string { + return url[url.length - 1] === '/' ? url : `${url}/`; } function removeLeadingSlash(value: string): string { - return value.charAt(0) === '/' ? value.slice(1) : value; + return value[0] === '/' ? value.slice(1) : value; } /** diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts new file mode 100644 index 000000000000..1d5d7847fca6 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts @@ -0,0 +1,106 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileNotToExist, expectFileToMatch, writeFile } from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsgComponent } from './ssg/ssg.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: '**', + component: HomeComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + ); + + await writeFile( + 'src/app/app.config.ts', + ` + import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { routes } from './app.routes'; + import { provideClientHydration } from '@angular/platform-browser'; + import { APP_BASE_HREF } from '@angular/common'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideClientHydration(), + { + provide: APP_BASE_HREF, + useValue: '/', + }, + ], + }; + `, + ); + + // Generate components for the above routes + await silentNg('generate', 'component', 'home'); + await silentNg('generate', 'component', 'ssg'); + + await noSilentNg('build', '--output-mode=static'); + + for (const { lang, outputPath } of langTranslations) { + await expectFileToMatch(join(outputPath, 'index.html'), `

${lang}

`); + await expectFileToMatch(join(outputPath, 'ssg/index.html'), `

${lang}

`); + } + + // Check that server directory does not exist + assert( + !existsSync('dist/test-project/server'), + 'Server directory should not exist when output-mode is static', + ); +}