Skip to content

Commit 12ff37a

Browse files
committed
perf(@angular/ssr): cache generated inline CSS for HTML
Implement LRU cache for inlined CSS in server-side rendered HTML. This optimization significantly improves server-side rendering performance by reusing previously inlined styles and reducing the overhead of repeated CSS inlining. Performance improvements observed: Performance improvements observed: * **Latency:** Reduced by ~18.1% (from 1.01s to 827.47ms) * **Requests per Second:** Increased by ~24.1% (from 381.16 to 472.85) * **Transfer per Second:** Increased by ~24.1% (from 0.87MB to 1.08MB) These gains demonstrate the effectiveness of caching inlined CSS for frequently accessed pages, resulting in a faster and more efficient user experience.
1 parent be3f3ff commit 12ff37a

File tree

7 files changed

+310
-1
lines changed

7 files changed

+310
-1
lines changed

packages/angular/ssr/src/app.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ import { getAngularAppManifest } from './manifest';
1313
import { RenderMode } from './routes/route-config';
1414
import { ServerRouter } from './routes/router';
1515
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
16+
import { sha256 } from './utils/crypto';
1617
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
18+
import { LRUCache } from './utils/lru-cache';
1719
import { AngularBootstrap, renderAngular } from './utils/ng';
1820

21+
/**
22+
* Maximum number of critical CSS entries the cache can store.
23+
* This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages.
24+
*/
25+
const MAX_INLINE_CSS_CACHE_ENTRIES = 50;
26+
1927
/**
2028
* A mapping of `RenderMode` enum values to corresponding string representations.
2129
*
@@ -72,6 +80,15 @@ export class AngularServerApp {
7280
*/
7381
private boostrap: AngularBootstrap | undefined;
7482

83+
/**
84+
* Cache for storing critical CSS for pages.
85+
* Stores a maximum of MAX_INLINE_CSS_CACHE_ENTRIES entries.
86+
*
87+
* Uses an LRU (Least Recently Used) eviction policy, meaning that when the cache is full,
88+
* the least recently accessed page's critical CSS will be removed to make space for new entries.
89+
*/
90+
private readonly criticalCssLRUCache = new LRUCache<string, string>(MAX_INLINE_CSS_CACHE_ENTRIES);
91+
7592
/**
7693
* Renders a response for the given HTTP request using the server application.
7794
*
@@ -237,7 +254,19 @@ export class AngularServerApp {
237254
return this.assets.getServerAsset(fileName);
238255
});
239256

240-
html = await this.inlineCriticalCssProcessor.process(html);
257+
if (isSsrMode) {
258+
// Only cache if we are running in SSR Mode.
259+
const cacheKey = await sha256(html);
260+
let htmlWithCriticalCss = this.criticalCssLRUCache.get(cacheKey);
261+
if (htmlWithCriticalCss === undefined) {
262+
htmlWithCriticalCss = await this.inlineCriticalCssProcessor.process(html);
263+
this.criticalCssLRUCache.put(cacheKey, htmlWithCriticalCss);
264+
}
265+
266+
html = htmlWithCriticalCss;
267+
} else {
268+
html = await this.inlineCriticalCssProcessor.process(html);
269+
}
241270
}
242271

243272
return new Response(html, responseInit);
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* Generates a SHA-256 hash of the provided string.
11+
*
12+
* @param data - The input string to be hashed.
13+
* @returns A promise that resolves to the SHA-256 hash of the input,
14+
* represented as a hexadecimal string.
15+
*/
16+
export async function sha256(data: string): Promise<string> {
17+
if (typeof crypto === 'undefined') {
18+
// TODO(alanagius): remove once Node.js version 18 is no longer supported.
19+
throw new Error(
20+
`The global 'crypto' module is unavailable. ` +
21+
`If you are running on Node.js, please ensure you are using version 20 or later, ` +
22+
`which includes built-in support for the Web Crypto module.`,
23+
);
24+
}
25+
26+
const encodedData = new TextEncoder().encode(data);
27+
const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData);
28+
const hashParts: string[] = [];
29+
30+
for (const h of new Uint8Array(hashBuffer)) {
31+
hashParts.push(h.toString(16).padStart(2, '0'));
32+
}
33+
34+
return hashParts.join('');
35+
}
+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* Represents a node in the doubly linked list.
11+
*/
12+
interface Node<Key, Value> {
13+
key: Key;
14+
value: Value;
15+
prev: Node<Key, Value> | undefined;
16+
next: Node<Key, Value> | undefined;
17+
}
18+
19+
/**
20+
* A Least Recently Used (LRU) cache implementation.
21+
*
22+
* This cache stores a fixed number of key-value pairs, and when the cache exceeds its capacity,
23+
* the least recently accessed items are evicted.
24+
*
25+
* @template Key - The type of the cache keys.
26+
* @template Value - The type of the cache values.
27+
*/
28+
export class LRUCache<Key, Value> {
29+
/**
30+
* The maximum number of items the cache can hold.
31+
*/
32+
capacity: number;
33+
34+
/**
35+
* Internal storage for the cache, mapping keys to their associated nodes in the linked list.
36+
*/
37+
private readonly cache = new Map<Key, Node<Key, Value>>();
38+
39+
/**
40+
* Head of the doubly linked list, representing the most recently used item.
41+
*/
42+
private head: Node<Key, Value> | undefined;
43+
44+
/**
45+
* Tail of the doubly linked list, representing the least recently used item.
46+
*/
47+
private tail: Node<Key, Value> | undefined;
48+
49+
/**
50+
* Creates a new LRUCache instance.
51+
* @param capacity The maximum number of items the cache can hold.
52+
*/
53+
constructor(capacity: number) {
54+
this.capacity = capacity;
55+
}
56+
57+
/**
58+
* Gets the value associated with the given key.
59+
* @param key The key to retrieve the value for.
60+
* @returns The value associated with the key, or undefined if the key is not found.
61+
*/
62+
get(key: Key): Value | undefined {
63+
const node = this.cache.get(key);
64+
if (node) {
65+
this.moveToHead(node);
66+
67+
return node.value;
68+
}
69+
70+
return undefined;
71+
}
72+
73+
/**
74+
* Puts a key-value pair into the cache.
75+
* If the key already exists, the value is updated.
76+
* If the cache is full, the least recently used item is evicted.
77+
* @param key The key to insert or update.
78+
* @param value The value to associate with the key.
79+
*/
80+
put(key: Key, value: Value): void {
81+
const cachedNode = this.cache.get(key);
82+
if (cachedNode) {
83+
// Update existing node
84+
cachedNode.value = value;
85+
this.moveToHead(cachedNode);
86+
87+
return;
88+
}
89+
90+
// Create a new node
91+
const newNode: Node<Key, Value> = { key, value, prev: undefined, next: undefined };
92+
this.cache.set(key, newNode);
93+
this.addToHead(newNode);
94+
95+
if (this.cache.size > this.capacity) {
96+
// Evict the LRU item
97+
const tail = this.removeTail();
98+
if (tail) {
99+
this.cache.delete(tail.key);
100+
}
101+
}
102+
}
103+
104+
/**
105+
* Adds a node to the head of the linked list.
106+
* @param node The node to add.
107+
*/
108+
private addToHead(node: Node<Key, Value>): void {
109+
node.next = this.head;
110+
node.prev = undefined;
111+
112+
if (this.head) {
113+
this.head.prev = node;
114+
}
115+
116+
this.head = node;
117+
118+
if (!this.tail) {
119+
this.tail = node;
120+
}
121+
}
122+
123+
/**
124+
* Removes a node from the linked list.
125+
* @param node The node to remove.
126+
*/
127+
private removeNode(node: Node<Key, Value>): void {
128+
if (node.prev) {
129+
node.prev.next = node.next;
130+
} else {
131+
this.head = node.next;
132+
}
133+
134+
if (node.next) {
135+
node.next.prev = node.prev;
136+
} else {
137+
this.tail = node.prev;
138+
}
139+
}
140+
141+
/**
142+
* Moves a node to the head of the linked list.
143+
* @param node The node to move.
144+
*/
145+
private moveToHead(node: Node<Key, Value>): void {
146+
this.removeNode(node);
147+
this.addToHead(node);
148+
}
149+
150+
/**
151+
* Removes the tail node from the linked list.
152+
* @returns The removed tail node, or undefined if the list is empty.
153+
*/
154+
private removeTail(): Node<Key, Value> | undefined {
155+
const node = this.tail;
156+
if (node) {
157+
this.removeNode(node);
158+
}
159+
160+
return node;
161+
}
162+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { LRUCache } from '../../src/utils/lru-cache';
10+
11+
describe('LRUCache', () => {
12+
let cache: LRUCache<string, number>;
13+
14+
beforeEach(() => {
15+
cache = new LRUCache<string, number>(3);
16+
});
17+
18+
it('should create a cache with the correct capacity', () => {
19+
expect(cache.capacity).toBe(3); // Test internal capacity
20+
});
21+
22+
it('should store and retrieve a key-value pair', () => {
23+
cache.put('a', 1);
24+
expect(cache.get('a')).toBe(1);
25+
});
26+
27+
it('should return undefined for non-existent keys', () => {
28+
expect(cache.get('nonExistentKey')).toBeUndefined();
29+
});
30+
31+
it('should remove the least recently used item when capacity is exceeded', () => {
32+
cache.put('a', 1);
33+
cache.put('b', 2);
34+
cache.put('c', 3);
35+
36+
// Cache is full now, adding another item should evict the least recently used ('a')
37+
cache.put('d', 4);
38+
39+
expect(cache.get('a')).toBeUndefined(); // 'a' should be evicted
40+
expect(cache.get('b')).toBe(2); // 'b', 'c', 'd' should remain
41+
expect(cache.get('c')).toBe(3);
42+
expect(cache.get('d')).toBe(4);
43+
});
44+
45+
it('should update the value if the key already exists', () => {
46+
cache.put('a', 1);
47+
cache.put('a', 10); // Update the value of 'a'
48+
49+
expect(cache.get('a')).toBe(10); // 'a' should have the updated value
50+
});
51+
52+
it('should move the accessed key to the most recently used position', () => {
53+
cache.put('a', 1);
54+
cache.put('b', 2);
55+
cache.put('c', 3);
56+
57+
// Access 'a', it should be moved to the most recently used position
58+
expect(cache.get('a')).toBe(1);
59+
60+
// Adding 'd' should now evict 'b', since 'a' was just accessed
61+
cache.put('d', 4);
62+
63+
expect(cache.get('b')).toBeUndefined(); // 'b' should be evicted
64+
expect(cache.get('a')).toBe(1); // 'a' should still be present
65+
expect(cache.get('c')).toBe(3);
66+
expect(cache.get('d')).toBe(4);
67+
});
68+
});

tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n-base-href.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
66
import { langTranslations, setupI18nConfig } from '../i18n/setup';
77

88
export default async function () {
9+
if (process.version.startsWith('v18')) {
10+
// This is not supported in Node.js version 18 as global web crypto module is not available.
11+
return;
12+
}
13+
914
// Setup project
1015
await setupI18nConfig();
1116
await setupProjectWithSSRAppEngine();

tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server-i18n.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
66
import { langTranslations, setupI18nConfig } from '../i18n/setup';
77

88
export default async function () {
9+
if (process.version.startsWith('v18')) {
10+
// This is not supported in Node.js version 18 as global web crypto module is not available.
11+
return;
12+
}
13+
914
// Setup project
1015
await setupI18nConfig();
1116
await setupProjectWithSSRAppEngine();

tests/legacy-cli/e2e/tests/server-rendering/server-routes-output-mode-server.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { noSilentNg, silentNg } from '../../utils/process';
66
import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
77

88
export default async function () {
9+
if (process.version.startsWith('v18')) {
10+
// This is not supported in Node.js version 18 as global web crypto module is not available.
11+
return;
12+
}
13+
914
// Setup project
1015
await setupProjectWithSSRAppEngine();
1116

0 commit comments

Comments
 (0)