Skip to content

Commit 63428f3

Browse files
committed
perf(@angular/ssr): flush headers prior to start rendering the HTML
This change ensures that HTTP headers are flushed to the client before the server begins rendering the Angular application HTML. By flushing headers early, the server can inform the client of response metadata (e.g., status code, content type) immediately, improving performance and user experience.
1 parent 9b682e6 commit 63428f3

File tree

2 files changed

+90
-41
lines changed

2 files changed

+90
-41
lines changed

packages/angular/ssr/src/app.ts

+25-19
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ export class AngularServerApp {
102102
constructor(private readonly options: Readonly<AngularServerAppOptions> = {}) {
103103
this.allowStaticRouteRender = this.options.allowStaticRouteRender ?? false;
104104
this.hooks = options.hooks ?? new Hooks();
105+
106+
if (this.manifest.inlineCriticalCss) {
107+
this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor((path: string) => {
108+
const fileName = path.split('/').pop() ?? path;
109+
110+
return this.assets.getServerAsset(fileName).text();
111+
});
112+
}
105113
}
106114

107115
/**
@@ -267,7 +275,7 @@ export class AngularServerApp {
267275
const platformProviders: StaticProvider[] = [];
268276

269277
const {
270-
manifest: { bootstrap, inlineCriticalCss, locale },
278+
manifest: { bootstrap, locale },
271279
assets,
272280
} = this;
273281

@@ -315,49 +323,47 @@ export class AngularServerApp {
315323
this.boostrap ??= await bootstrap();
316324
let html = await assets.getIndexServerHtml().text();
317325
html = await this.runTransformsOnHtml(html, url, preload);
318-
html = await renderAngular(
326+
327+
const { content } = await renderAngular(
319328
html,
320329
this.boostrap,
321330
url,
322331
platformProviders,
323332
SERVER_CONTEXT_VALUE[renderMode],
324333
);
325334

326-
if (!inlineCriticalCss) {
327-
return new Response(html, responseInit);
328-
}
329-
330-
this.inlineCriticalCssProcessor ??= new InlineCriticalCssProcessor((path: string) => {
331-
const fileName = path.split('/').pop() ?? path;
332-
333-
return this.assets.getServerAsset(fileName).text();
334-
});
335-
336335
const { inlineCriticalCssProcessor, criticalCssLRUCache, textDecoder } = this;
337336

338-
// Use a stream to send the response before inlining critical CSS, improving performance via header flushing.
337+
// Use a stream to send the response before finishing rendering and inling critical CSS, improving performance via header flushing.
339338
const stream = new ReadableStream({
340339
async start(controller) {
341-
let htmlWithCriticalCss;
340+
const renderedHtml = await content();
341+
342+
if (!inlineCriticalCssProcessor) {
343+
controller.enqueue(textDecoder.encode(renderedHtml));
344+
controller.close();
342345

346+
return;
347+
}
348+
349+
let htmlWithCriticalCss;
343350
try {
344351
if (renderMode === RenderMode.Server) {
345-
const cacheKey = await sha256(html);
352+
const cacheKey = await sha256(renderedHtml);
346353
htmlWithCriticalCss = criticalCssLRUCache.get(cacheKey);
347354
if (!htmlWithCriticalCss) {
348-
htmlWithCriticalCss = await inlineCriticalCssProcessor.process(html);
355+
htmlWithCriticalCss = await inlineCriticalCssProcessor.process(renderedHtml);
349356
criticalCssLRUCache.put(cacheKey, htmlWithCriticalCss);
350357
}
351358
} else {
352-
htmlWithCriticalCss = await inlineCriticalCssProcessor.process(html);
359+
htmlWithCriticalCss = await inlineCriticalCssProcessor.process(renderedHtml);
353360
}
354361
} catch (error) {
355362
// eslint-disable-next-line no-console
356363
console.error(`An error occurred while inlining critical CSS for: ${url}.`, error);
357364
}
358365

359-
controller.enqueue(textDecoder.encode(htmlWithCriticalCss ?? html));
360-
366+
controller.enqueue(textDecoder.encode(htmlWithCriticalCss ?? renderedHtml));
361367
controller.close();
362368
},
363369
});

packages/angular/ssr/src/utils/ng.ts

+65-22
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { ɵConsole } from '@angular/core';
10-
import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
119
import {
10+
ApplicationRef,
11+
type PlatformRef,
12+
type StaticProvider,
13+
type Type,
14+
ɵConsole,
15+
} from '@angular/core';
16+
import {
17+
INITIAL_CONFIG,
1218
ɵSERVER_CONTEXT as SERVER_CONTEXT,
13-
renderApplication,
14-
renderModule,
19+
platformServer,
20+
ɵrenderInternal as renderInternal,
1521
} from '@angular/platform-server';
1622
import { Console } from '../console';
1723
import { stripIndexHtmlFromURL } from './url';
@@ -41,16 +47,26 @@ export type AngularBootstrap = Type<unknown> | (() => Promise<ApplicationRef>);
4147
* rendering process.
4248
* @param serverContext - A string representing the server context, used to provide additional
4349
* context or metadata during server-side rendering.
44-
* @returns A promise that resolves to a string containing the rendered HTML.
50+
* @returns A promise resolving to an object containing a `content` method, which returns a
51+
* promise that resolves to the rendered HTML string.
4552
*/
46-
export function renderAngular(
53+
export async function renderAngular(
4754
html: string,
4855
bootstrap: AngularBootstrap,
4956
url: URL,
5057
platformProviders: StaticProvider[],
5158
serverContext: string,
52-
): Promise<string> {
53-
const providers = [
59+
): Promise<{ content: () => Promise<string> }> {
60+
// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
61+
const urlToRender = stripIndexHtmlFromURL(url).toString();
62+
const platformRef = platformServer([
63+
{
64+
provide: INITIAL_CONFIG,
65+
useValue: {
66+
url: urlToRender,
67+
document: html,
68+
},
69+
},
5470
{
5571
provide: SERVER_CONTEXT,
5672
useValue: serverContext,
@@ -64,22 +80,34 @@ export function renderAngular(
6480
useFactory: () => new Console(),
6581
},
6682
...platformProviders,
67-
];
83+
]);
6884

69-
// A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`.
70-
const urlToRender = stripIndexHtmlFromURL(url).toString();
85+
try {
86+
let applicationRef: ApplicationRef;
87+
if (isNgModule(bootstrap)) {
88+
const moduleRef = await platformRef.bootstrapModule(bootstrap);
89+
applicationRef = moduleRef.injector.get(ApplicationRef);
90+
} else {
91+
applicationRef = await bootstrap();
92+
}
7193

72-
return isNgModule(bootstrap)
73-
? renderModule(bootstrap, {
74-
url: urlToRender,
75-
document: html,
76-
extraProviders: providers,
77-
})
78-
: renderApplication(bootstrap, {
79-
url: urlToRender,
80-
document: html,
81-
platformProviders: providers,
82-
});
94+
// Block until application is stable.
95+
await applicationRef.whenStable();
96+
97+
return {
98+
content: async () => {
99+
try {
100+
return renderInternal(platformRef, applicationRef);
101+
} finally {
102+
await asyncDestroyPlatform(platformRef);
103+
}
104+
},
105+
};
106+
} catch (error) {
107+
await asyncDestroyPlatform(platformRef);
108+
109+
throw error;
110+
}
83111
}
84112

85113
/**
@@ -93,3 +121,18 @@ export function renderAngular(
93121
export function isNgModule(value: AngularBootstrap): value is Type<unknown> {
94122
return 'ɵmod' in value;
95123
}
124+
125+
/**
126+
* Gracefully destroys the application in a macrotask, allowing pending promises to resolve
127+
* and surfacing any potential errors to the user.
128+
*
129+
* @param platformRef - The platform reference to be destroyed.
130+
*/
131+
function asyncDestroyPlatform(platformRef: PlatformRef): Promise<void> {
132+
return new Promise((resolve) => {
133+
setTimeout(() => {
134+
platformRef.destroy();
135+
resolve();
136+
}, 0);
137+
});
138+
}

0 commit comments

Comments
 (0)