Skip to content

Commit

Permalink
perf(@angular/ssr): cache generated inline CSS for HTML
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alan-agius4 committed Sep 30, 2024
1 parent be3f3ff commit 12ff37a
Show file tree
Hide file tree
Showing 7 changed files with 310 additions and 1 deletion.
31 changes: 30 additions & 1 deletion packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ import { getAngularAppManifest } from './manifest';
import { RenderMode } from './routes/route-config';
import { ServerRouter } from './routes/router';
import { REQUEST, REQUEST_CONTEXT, RESPONSE_INIT } from './tokens';
import { sha256 } from './utils/crypto';
import { InlineCriticalCssProcessor } from './utils/inline-critical-css';
import { LRUCache } from './utils/lru-cache';
import { AngularBootstrap, renderAngular } from './utils/ng';

/**
* Maximum number of critical CSS entries the cache can store.
* This value determines the capacity of the LRU (Least Recently Used) cache, which stores critical CSS for pages.
*/
const MAX_INLINE_CSS_CACHE_ENTRIES = 50;

/**
* A mapping of `RenderMode` enum values to corresponding string representations.
*
Expand Down Expand Up @@ -72,6 +80,15 @@ export class AngularServerApp {
*/
private boostrap: AngularBootstrap | undefined;

/**
* Cache for storing critical CSS for pages.
* Stores a maximum of MAX_INLINE_CSS_CACHE_ENTRIES entries.
*
* Uses an LRU (Least Recently Used) eviction policy, meaning that when the cache is full,
* the least recently accessed page's critical CSS will be removed to make space for new entries.
*/
private readonly criticalCssLRUCache = new LRUCache<string, string>(MAX_INLINE_CSS_CACHE_ENTRIES);

/**
* Renders a response for the given HTTP request using the server application.
*
Expand Down Expand Up @@ -237,7 +254,19 @@ export class AngularServerApp {
return this.assets.getServerAsset(fileName);
});

html = await this.inlineCriticalCssProcessor.process(html);
if (isSsrMode) {
// Only cache if we are running in SSR Mode.
const cacheKey = await sha256(html);
let htmlWithCriticalCss = this.criticalCssLRUCache.get(cacheKey);
if (htmlWithCriticalCss === undefined) {
htmlWithCriticalCss = await this.inlineCriticalCssProcessor.process(html);
this.criticalCssLRUCache.put(cacheKey, htmlWithCriticalCss);
}

html = htmlWithCriticalCss;
} else {
html = await this.inlineCriticalCssProcessor.process(html);
}
}

return new Response(html, responseInit);
Expand Down
35 changes: 35 additions & 0 deletions packages/angular/ssr/src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

/**
* Generates a SHA-256 hash of the provided string.
*
* @param data - The input string to be hashed.
* @returns A promise that resolves to the SHA-256 hash of the input,
* represented as a hexadecimal string.
*/
export async function sha256(data: string): Promise<string> {
if (typeof crypto === 'undefined') {
// TODO(alanagius): remove once Node.js version 18 is no longer supported.
throw new Error(
`The global 'crypto' module is unavailable. ` +
`If you are running on Node.js, please ensure you are using version 20 or later, ` +
`which includes built-in support for the Web Crypto module.`,
);
}

const encodedData = new TextEncoder().encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData);
const hashParts: string[] = [];

for (const h of new Uint8Array(hashBuffer)) {
hashParts.push(h.toString(16).padStart(2, '0'));
}

return hashParts.join('');
}
162 changes: 162 additions & 0 deletions packages/angular/ssr/src/utils/lru-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

/**
* Represents a node in the doubly linked list.
*/
interface Node<Key, Value> {
key: Key;
value: Value;
prev: Node<Key, Value> | undefined;
next: Node<Key, Value> | undefined;
}

/**
* A Least Recently Used (LRU) cache implementation.
*
* This cache stores a fixed number of key-value pairs, and when the cache exceeds its capacity,
* the least recently accessed items are evicted.
*
* @template Key - The type of the cache keys.
* @template Value - The type of the cache values.
*/
export class LRUCache<Key, Value> {
/**
* The maximum number of items the cache can hold.
*/
capacity: number;

/**
* Internal storage for the cache, mapping keys to their associated nodes in the linked list.
*/
private readonly cache = new Map<Key, Node<Key, Value>>();

/**
* Head of the doubly linked list, representing the most recently used item.
*/
private head: Node<Key, Value> | undefined;

/**
* Tail of the doubly linked list, representing the least recently used item.
*/
private tail: Node<Key, Value> | undefined;

/**
* Creates a new LRUCache instance.
* @param capacity The maximum number of items the cache can hold.
*/
constructor(capacity: number) {
this.capacity = capacity;
}

/**
* Gets the value associated with the given key.
* @param key The key to retrieve the value for.
* @returns The value associated with the key, or undefined if the key is not found.
*/
get(key: Key): Value | undefined {
const node = this.cache.get(key);
if (node) {
this.moveToHead(node);

return node.value;
}

return undefined;
}

/**
* Puts a key-value pair into the cache.
* If the key already exists, the value is updated.
* If the cache is full, the least recently used item is evicted.
* @param key The key to insert or update.
* @param value The value to associate with the key.
*/
put(key: Key, value: Value): void {
const cachedNode = this.cache.get(key);
if (cachedNode) {
// Update existing node
cachedNode.value = value;
this.moveToHead(cachedNode);

return;
}

// Create a new node
const newNode: Node<Key, Value> = { key, value, prev: undefined, next: undefined };
this.cache.set(key, newNode);
this.addToHead(newNode);

if (this.cache.size > this.capacity) {
// Evict the LRU item
const tail = this.removeTail();
if (tail) {
this.cache.delete(tail.key);
}
}
}

/**
* Adds a node to the head of the linked list.
* @param node The node to add.
*/
private addToHead(node: Node<Key, Value>): void {
node.next = this.head;
node.prev = undefined;

if (this.head) {
this.head.prev = node;
}

this.head = node;

if (!this.tail) {
this.tail = node;
}
}

/**
* Removes a node from the linked list.
* @param node The node to remove.
*/
private removeNode(node: Node<Key, Value>): void {
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}

if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
}

/**
* Moves a node to the head of the linked list.
* @param node The node to move.
*/
private moveToHead(node: Node<Key, Value>): void {
this.removeNode(node);
this.addToHead(node);
}

/**
* Removes the tail node from the linked list.
* @returns The removed tail node, or undefined if the list is empty.
*/
private removeTail(): Node<Key, Value> | undefined {
const node = this.tail;
if (node) {
this.removeNode(node);
}

return node;
}
}
68 changes: 68 additions & 0 deletions packages/angular/ssr/test/utils/lru_cache_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { LRUCache } from '../../src/utils/lru-cache';

describe('LRUCache', () => {
let cache: LRUCache<string, number>;

beforeEach(() => {
cache = new LRUCache<string, number>(3);
});

it('should create a cache with the correct capacity', () => {
expect(cache.capacity).toBe(3); // Test internal capacity
});

it('should store and retrieve a key-value pair', () => {
cache.put('a', 1);
expect(cache.get('a')).toBe(1);
});

it('should return undefined for non-existent keys', () => {
expect(cache.get('nonExistentKey')).toBeUndefined();
});

it('should remove the least recently used item when capacity is exceeded', () => {
cache.put('a', 1);
cache.put('b', 2);
cache.put('c', 3);

// Cache is full now, adding another item should evict the least recently used ('a')
cache.put('d', 4);

expect(cache.get('a')).toBeUndefined(); // 'a' should be evicted
expect(cache.get('b')).toBe(2); // 'b', 'c', 'd' should remain
expect(cache.get('c')).toBe(3);
expect(cache.get('d')).toBe(4);
});

it('should update the value if the key already exists', () => {
cache.put('a', 1);
cache.put('a', 10); // Update the value of 'a'

expect(cache.get('a')).toBe(10); // 'a' should have the updated value
});

it('should move the accessed key to the most recently used position', () => {
cache.put('a', 1);
cache.put('b', 2);
cache.put('c', 3);

// Access 'a', it should be moved to the most recently used position
expect(cache.get('a')).toBe(1);

// Adding 'd' should now evict 'b', since 'a' was just accessed
cache.put('d', 4);

expect(cache.get('b')).toBeUndefined(); // 'b' should be evicted
expect(cache.get('a')).toBe(1); // 'a' should still be present
expect(cache.get('c')).toBe(3);
expect(cache.get('d')).toBe(4);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
import { langTranslations, setupI18nConfig } from '../i18n/setup';

export default async function () {
if (process.version.startsWith('v18')) {
// This is not supported in Node.js version 18 as global web crypto module is not available.
return;
}

// Setup project
await setupI18nConfig();
await setupProjectWithSSRAppEngine();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { setupProjectWithSSRAppEngine, spawnServer } from './setup';
import { langTranslations, setupI18nConfig } from '../i18n/setup';

export default async function () {
if (process.version.startsWith('v18')) {
// This is not supported in Node.js version 18 as global web crypto module is not available.
return;
}

// Setup project
await setupI18nConfig();
await setupProjectWithSSRAppEngine();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { noSilentNg, silentNg } from '../../utils/process';
import { setupProjectWithSSRAppEngine, spawnServer } from './setup';

export default async function () {
if (process.version.startsWith('v18')) {
// This is not supported in Node.js version 18 as global web crypto module is not available.
return;
}

// Setup project
await setupProjectWithSSRAppEngine();

Expand Down

0 comments on commit 12ff37a

Please sign in to comment.