Skip to content

Commit 4fa81d4

Browse files
committed
fix(@angular/ssr): decode pathname when constructing server asset path
Ensure that the request pathname is decoded using `decodeURIComponent` when resolving server asset paths in `AngularServerApp`. Previously, `buildServerAssetPathFromRequest` used the raw percent-encoded `pathname` from `new URL(request.url)` while prerendered assets were registered in the manifest under decoded names. Consequently, prerendered routes with non-ASCII or percent-encoded characters failed asset lookup and fell through to server-side rendering or 404. Closes #33966
1 parent ab6d7df commit 4fa81d4

3 files changed

Lines changed: 164 additions & 37 deletions

File tree

packages/angular/ssr/src/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,12 +455,19 @@ export class AngularServerApp {
455455
*/
456456
private buildServerAssetPathFromRequest(request: Request): string {
457457
let { pathname: assetPath } = new URL(request.url);
458+
try {
459+
assetPath = decodeURIComponent(assetPath);
460+
} catch {
461+
// In case of malformed URI component, keep assetPath as is.
462+
}
463+
458464
if (!assetPath.endsWith('/index.html')) {
459465
// Append "index.html" to build the default asset path.
460466
assetPath = joinUrlParts(assetPath, 'index.html');
461467
}
462468

463469
const { baseHref } = this.manifest;
470+
464471
// Check if the asset path starts with the base href and the base href is not (`/` or ``).
465472
if (baseHref.length > 1 && assetPath.startsWith(baseHref)) {
466473
// Remove the base href from the start of the asset path to align with server-asset expectations.

packages/angular/ssr/test/app-engine_spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ function createEntryPoint(locale: string) {
5454
setAngularAppTestingManifest(
5555
[
5656
{ path: 'ssg', component: SSGComponent },
57+
{ path: 'ssg-non-ascii/دليل', component: SSGComponent },
5758
{ path: 'ssr', component: SSRComponent },
5859
{ path: '', component: HomeComponent },
5960
],
6061
[
6162
{ path: 'ssg', renderMode: RenderMode.Prerender },
63+
{ path: 'ssg-non-ascii/دليل', renderMode: RenderMode.Prerender },
6264
{ path: '**', renderMode: RenderMode.Server },
6365
],
6466
'/' + locale,
@@ -77,6 +79,20 @@ function createEntryPoint(locale: string) {
7779
</html>
7880
`,
7981
},
82+
'ssg-non-ascii/دليل/index.html': {
83+
size: 35,
84+
hash: 'a1b2c3d4e5f6',
85+
text: async () => `<html>
86+
<head>
87+
<title>SSG non-ascii page</title>
88+
<base href="/${locale}" />
89+
</head>
90+
<body>
91+
SSG non-ascii works ${locale.toUpperCase()}
92+
</body>
93+
</html>
94+
`,
95+
},
8096
},
8197
locale,
8298
);
@@ -147,6 +163,15 @@ describe('AngularAppEngine', () => {
147163
expect(await response?.text()).toContain('SSG works IT');
148164
});
149165

166+
it('should return a served prerendered page for non-ASCII routes with correct locale', async () => {
167+
const request = new Request(
168+
'https://example.com/it/ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84',
169+
);
170+
const response = await appEngine.handle(request);
171+
expect(await response?.text()).toContain('SSG non-ascii works IT');
172+
expect(response?.headers?.get('Content-Language')).toBe('it');
173+
});
174+
150175
it('should return null for requests to unknown pages in a locale', async () => {
151176
const request = new Request('https://example.com/it/unknown/page');
152177
const response = await appEngine.handle(request);

packages/angular/ssr/test/app_spec.ts

Lines changed: 132 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,58 +22,59 @@ import { setAngularAppTestingManifest } from './testing-utils';
2222
describe('AngularServerApp', () => {
2323
let app: AngularServerApp;
2424

25-
beforeAll(() => {
26-
@Component({
27-
selector: 'app-home',
28-
template: `Home works`,
29-
})
30-
class HomeComponent {
31-
constructor() {
32-
if (inject(ActivatedRoute).snapshot.data['destroyApp']) {
33-
inject(PlatformRef).destroy();
34-
}
25+
@Component({
26+
selector: 'app-home',
27+
template: `Home works`,
28+
})
29+
class HomeComponent {
30+
constructor() {
31+
if (inject(ActivatedRoute).snapshot.data['destroyApp']) {
32+
inject(PlatformRef).destroy();
3533
}
3634
}
37-
38-
@Component({
39-
selector: 'app-redirect',
40-
})
41-
class RedirectComponent {
42-
constructor() {
43-
const responseInit = inject(RESPONSE_INIT);
44-
if (responseInit) {
45-
responseInit.status = 308;
46-
const headers = responseInit.headers;
47-
if (headers) {
48-
(headers as Headers).set('X-Redirect-Header', 'custom-value');
49-
}
35+
}
36+
37+
@Component({
38+
selector: 'app-redirect',
39+
})
40+
class RedirectComponent {
41+
constructor() {
42+
const responseInit = inject(RESPONSE_INIT);
43+
if (responseInit) {
44+
responseInit.status = 308;
45+
const headers = responseInit.headers;
46+
if (headers) {
47+
(headers as Headers).set('X-Redirect-Header', 'custom-value');
5048
}
51-
52-
void inject(Router).navigate([], {
53-
queryParams: { filter: 'test' },
54-
});
5549
}
56-
}
5750

58-
const queryParamAdderGuard: CanActivateFn = (_route, state) => {
59-
const urlTree = inject(Router).parseUrl(state.url);
51+
void inject(Router).navigate([], {
52+
queryParams: { filter: 'test' },
53+
});
54+
}
55+
}
6056

61-
if (urlTree.queryParamMap.has('filter')) {
62-
return true;
63-
}
57+
const queryParamAdderGuard: CanActivateFn = (_route, state) => {
58+
const urlTree = inject(Router).parseUrl(state.url);
6459

65-
urlTree.queryParams = {
66-
filter: 'test',
67-
};
60+
if (urlTree.queryParamMap.has('filter')) {
61+
return true;
62+
}
6863

69-
return urlTree;
64+
urlTree.queryParams = {
65+
filter: 'test',
7066
};
7167

68+
return urlTree;
69+
};
70+
71+
function setupManifest(): void {
7272
setAngularAppTestingManifest(
7373
[
7474
{ path: 'home', component: HomeComponent },
7575
{ path: 'home-csr', component: HomeComponent },
7676
{ path: 'home-ssg', component: HomeComponent },
77+
{ path: 'home-ssg-non-ascii/دليل', component: HomeComponent },
7778
{ path: 'page-with-headers', component: HomeComponent },
7879
{ path: 'page-with-status', component: HomeComponent },
7980
{ path: 'page-destroy-app', component: HomeComponent, data: { destroyApp: true } },
@@ -105,6 +106,10 @@ describe('AngularServerApp', () => {
105106
'X-Some-Header': 'value',
106107
},
107108
},
109+
{
110+
path: 'home-ssg-non-ascii/دليل',
111+
renderMode: RenderMode.Prerender,
112+
},
108113
{
109114
path: 'page-with-status',
110115
renderMode: RenderMode.Server,
@@ -140,6 +145,21 @@ describe('AngularServerApp', () => {
140145
size: 28,
141146
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
142147
},
148+
'home-ssg-non-ascii/دليل/index.html': {
149+
text: async () =>
150+
`<html>
151+
<head>
152+
<title>SSG non-ascii page</title>
153+
<base href="/" />
154+
</head>
155+
<body>
156+
<app-root>Home SSG non-ascii works</app-root>
157+
</body>
158+
</html>
159+
`,
160+
size: 38,
161+
hash: 'a1b2c3d4e5f6',
162+
},
143163
},
144164
undefined,
145165
undefined,
@@ -152,6 +172,10 @@ describe('AngularServerApp', () => {
152172
);
153173

154174
app = new AngularServerApp();
175+
}
176+
177+
beforeAll(() => {
178+
setupManifest();
155179
});
156180

157181
describe('handle', () => {
@@ -282,6 +306,77 @@ describe('AngularServerApp', () => {
282306
expect(await response?.text()).toContain('Home SSG works');
283307
});
284308

309+
it('should correctly serve prerendered page with non-ASCII path', async () => {
310+
const response = await app.handle(
311+
new Request('http://localhost/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84'),
312+
);
313+
expect(await response?.text()).toContain('Home SSG non-ascii works');
314+
});
315+
316+
it(`should correctly serve prerendered page with non-ASCII path when the URL ends with 'index.html'`, async () => {
317+
const response = await app.handle(
318+
new Request('http://localhost/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84/index.html'),
319+
);
320+
expect(await response?.text()).toContain('Home SSG non-ascii works');
321+
});
322+
323+
it('should correctly serve prerendered page when requested with decoded non-ASCII characters', async () => {
324+
const response = await app.handle(new Request('http://localhost/home-ssg-non-ascii/دليل'));
325+
expect(await response?.text()).toContain('Home SSG non-ascii works');
326+
});
327+
328+
it('should correctly serve prerendered page with non-ASCII path when baseHref is configured', async () => {
329+
setAngularAppTestingManifest(
330+
[{ path: 'home-ssg-non-ascii/دليل', component: HomeComponent }],
331+
[
332+
{
333+
path: 'home-ssg-non-ascii/دليل',
334+
renderMode: RenderMode.Prerender,
335+
},
336+
],
337+
'/ar/',
338+
{
339+
'home-ssg-non-ascii/دليل/index.html': {
340+
text: async () => '<html><body>SSG with baseHref works</body></html>',
341+
size: 47,
342+
hash: '123456',
343+
},
344+
},
345+
);
346+
347+
const customApp = new AngularServerApp();
348+
const response = await customApp.handle(
349+
new Request('http://localhost/ar/home-ssg-non-ascii/%D8%AF%D9%84%D9%8A%D9%84'),
350+
);
351+
expect(await response?.text()).toContain('SSG with baseHref works');
352+
});
353+
354+
it('should correctly serve prerendered page when baseHref contains non-ASCII characters', async () => {
355+
setAngularAppTestingManifest(
356+
[{ path: 'page', component: HomeComponent }],
357+
[
358+
{
359+
path: 'page',
360+
renderMode: RenderMode.Prerender,
361+
},
362+
],
363+
'/دليل/',
364+
{
365+
'page/index.html': {
366+
text: async () => '<html><body>SSG with non-ASCII baseHref works</body></html>',
367+
size: 57,
368+
hash: 'abcdef',
369+
},
370+
},
371+
);
372+
373+
const customApp = new AngularServerApp();
374+
const response = await customApp.handle(
375+
new Request('http://localhost/%D8%AF%D9%84%D9%8A%D9%84/page'),
376+
);
377+
expect(await response?.text()).toContain('SSG with non-ASCII baseHref works');
378+
});
379+
285380
it('should return configured headers for pages with specific header settings', async () => {
286381
const response = await app.handle(new Request('http://localhost/home-ssg'));
287382
const headers = response?.headers.entries() ?? [];

0 commit comments

Comments
 (0)