From 93718aeef8f9bb596a84e32ec61af20b0e8183ce Mon Sep 17 00:00:00 2001 From: Jim Simon Date: Mon, 22 Sep 2025 17:24:13 -0400 Subject: [PATCH 1/6] [labs/ssr] Add performance benchmarking --- package-lock.json | 80 --- packages/labs/ssr/.gitignore | 1 + packages/labs/ssr/package.json | 66 ++- packages/labs/ssr/src/benchmarks/README.md | 249 ++++++++++ packages/labs/ssr/src/benchmarks/cli.ts | 81 ++++ .../ssr/src/benchmarks/comment-templates.ts | 289 +++++++++++ .../src/benchmarks/comment-tree-generator.ts | 305 ++++++++++++ packages/labs/ssr/src/benchmarks/index.ts | 17 + .../src/benchmarks/node-performance-tests.ts | 454 ++++++++++++++++++ 9 files changed, 1461 insertions(+), 81 deletions(-) create mode 100644 packages/labs/ssr/src/benchmarks/README.md create mode 100644 packages/labs/ssr/src/benchmarks/cli.ts create mode 100644 packages/labs/ssr/src/benchmarks/comment-templates.ts create mode 100644 packages/labs/ssr/src/benchmarks/comment-tree-generator.ts create mode 100644 packages/labs/ssr/src/benchmarks/index.ts create mode 100644 packages/labs/ssr/src/benchmarks/node-performance-tests.ts diff --git a/package-lock.json b/package-lock.json index 822047ab81..62fb0d642f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9011,22 +9011,6 @@ "node": ">=10" } }, - "node_modules/@wdio/config/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@wdio/config/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -9153,22 +9137,6 @@ } } }, - "node_modules/@wdio/utils/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@wdio/utils/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -14726,22 +14694,6 @@ "dev": true, "license": "MIT" }, - "node_modules/devtools/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/devtools/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -32796,22 +32748,6 @@ } } }, - "node_modules/webdriver/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/webdriver/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -33007,22 +32943,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/webdriverio/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/webdriverio/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/packages/labs/ssr/.gitignore b/packages/labs/ssr/.gitignore index 6b3c0425e9..87b40fc6df 100644 --- a/packages/labs/ssr/.gitignore +++ b/packages/labs/ssr/.gitignore @@ -1,3 +1,4 @@ +/benchmarks/ /demo/ /lib/ /node_modules/ diff --git a/packages/labs/ssr/package.json b/packages/labs/ssr/package.json index b827d528f7..61191b9119 100644 --- a/packages/labs/ssr/package.json +++ b/packages/labs/ssr/package.json @@ -19,7 +19,14 @@ "test:integration:shimmed:prod": "wireit", "test:integration:unshimmed:prod": "wireit", "test:unit": "wireit", - "test:types": "wireit" + "test:types": "wireit", + "benchmark": "wireit", + "benchmark:shallow-wide": "wireit", + "benchmark:deep-narrow": "wireit", + "benchmark:balanced": "wireit", + "benchmark:stress-test": "wireit", + "benchmark:comparison": "wireit", + "benchmark:all": "wireit" }, "wireit": { "build": { @@ -190,6 +197,63 @@ "tsconfig.json" ], "output": [] + }, + "benchmark": { + "command": "node benchmarks/cli.js", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:shallow-wide": { + "command": "node benchmarks/cli.js shallow_wide", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:deep-narrow": { + "command": "node benchmarks/cli.js deep_narrow", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:balanced": { + "command": "node benchmarks/cli.js balanced", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:stress-test": { + "command": "node benchmarks/cli.js stress_test", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:comparison": { + "command": "node benchmarks/cli.js reddit_frontpage", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, + "benchmark:all": { + "dependencies": [ + "benchmark:shallow-wide", + "benchmark:deep-narrow", + "benchmark:balanced", + "benchmark:stress-test", + "benchmark:comparison" + ] } }, "files": [ diff --git a/packages/labs/ssr/src/benchmarks/README.md b/packages/labs/ssr/src/benchmarks/README.md new file mode 100644 index 0000000000..2ba332a85f --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/README.md @@ -0,0 +1,249 @@ +# SSR Performance Benchmarks + +This directory contains Node.js performance tests for Lit SSR using Reddit-style comment trees to simulate deeply nested and wide content structures. + +## Overview + +The benchmark suite tests SSR performance with various comment tree structures: + +- **Shallow Wide**: Many top-level comments with few nested replies (popular posts) +- **Deep Narrow**: Long comment chains with deep nesting but few siblings +- **Balanced**: Typical Reddit structures with moderate depth and width +- **Stress Test**: Extreme scenarios with very large trees +- **Comparison**: Tests comparing full vs minimal template rendering + +## Architecture + +- **No Web Server**: Tests call SSR's `render()` function directly +- **Plain Lit Templates**: Uses `lit-html` templates without LitElements +- **Generator Iteration**: Measures time to iterate through the render generator +- **Deterministic Data**: Tree structures are generated deterministically for reproducible results +- **Configurable Scenarios**: Various tree structures with realistic content patterns +- **Node.js Performance**: Uses Node.js `performance.now()` for high-precision timing + +## Files + +- `comment-tree-generator.ts` - Generates Reddit-style comment tree data +- `comment-templates.ts` - Lit templates for rendering comment trees +- `node-performance-tests.ts` - Main Node.js performance test suite +- `cli.ts` - Command-line interface for running benchmarks + +## Running Benchmarks + +From the SSR package directory: + +```bash +# Build the benchmark files +npm run build + +# Run all benchmark scenarios +npm run benchmark:all + +# Run individual scenarios +npm run benchmark:shallow-wide +npm run benchmark:deep-narrow +npm run benchmark:balanced +npm run benchmark:stress-test +npm run benchmark:comparison + +# Run the default mixed workload test +npm run benchmark +``` + +### Using Wireit + +All benchmark commands use Wireit and will automatically build dependencies: + +```bash +# This will build first, then run benchmarks +WIREIT_LOGGER=simple npm run benchmark:balanced +``` + +### Custom Options + +You can customize benchmark behavior with command-line options: + +```bash +# Run with more iterations +npm run benchmark:balanced -- --iterations=20 + +# Run with longer warmup +npm run benchmark:stress-test -- --warmup=5 + +# Run quietly (no progress output) +npm run benchmark:shallow-wide -- --quiet + +# Combine options +npm run benchmark -- --iterations=5 --warmup=2 --quiet +``` + +### Available Options + +- `--iterations=N` - Number of test iterations per scenario (overrides scenario defaults) +- `--warmup=N` - Number of warmup iterations (overrides scenario defaults) +- `--quiet` - Suppress progress output for CI/automated use + +**Note**: Each scenario has its own default iteration and warmup counts optimized for that test type. CLI flags override these scenario-specific defaults. + +**Automatic Testing**: Each scenario automatically tests 4 variants: + +- **DSD**: Full template with declarative shadow DOM enabled +- **No DSD**: Full template with declarative shadow DOM disabled +- **DSD Minimal**: Minimal template with declarative shadow DOM enabled +- **No DSD Minimal**: Minimal template with declarative shadow DOM disabled + +## Test Configuration + +Each scenario has different iteration counts and characteristics configured in `COMMENT_TREE_CONFIGS`: + +### Shallow Wide + +- **Iterations**: 10 (default, can override with `--iterations=N`) +- **Warmup**: 3 iterations (default, can override with `--warmup=N`) +- **Characteristics**: Many top-level comments with few nested replies +- **Use Case**: Popular posts with broad engagement + +### Deep Narrow + +- **Iterations**: 15 (default, can override with `--iterations=N`) +- **Warmup**: 5 iterations (default, can override with `--warmup=N`) +- **Characteristics**: Long comment chains with deep nesting but few siblings +- **Use Case**: Long debates and discussion threads + +### Balanced + +- **Iterations**: 10 (default, can override with `--iterations=N`) +- **Warmup**: 3 iterations (default, can override with `--warmup=N`) +- **Characteristics**: Moderate depth and width - typical Reddit structures +- **Use Case**: Most common real-world scenarios + +### Stress Test + +- **Iterations**: 5 (default, can override with `--iterations=N`) +- **Warmup**: 2 iterations (default, can override with `--warmup=N`) +- **Characteristics**: Extreme scale with very large trees +- **Use Case**: Performance limits testing + +### Reddit Frontpage (Comparison) + +- **Iterations**: 8 (default, can override with `--iterations=N`) +- **Warmup**: 2 iterations (default, can override with `--warmup=N`) +- **Characteristics**: Large-scale Reddit frontpage scenario +- **Use Case**: Template optimization analysis and scaling tests + +## Metrics + +The benchmarks track several performance metrics for each of the 4 variants: + +- **Render Time**: Time to render templates (milliseconds) +- **Percentiles**: p75, p90, p95 response time distribution +- **Template Size**: Size of rendered HTML (bytes/KB) +- **Comment Count**: Number of comments in test data +- **Success Rate**: Percentage of successful renders (āœ…/āŒ indicators) +- **Performance Comparisons**: DSD vs No DSD impact, Full vs Minimal speedup + +## Example Output + +``` +šŸŽÆ Results for SHALLOW_WIDE: + Iterations: 1 per variant + + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ Variant │ Avg (ms) │ p75 (ms) │ p90 (ms) │ p95 (ms) │ Size (KB) │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ āœ… DSD │ 125.2 │ 125.2 │ 125.2 │ 125.2 │ 3531.1 │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ āœ… No DSD │ 96.9 │ 96.9 │ 96.9 │ 96.9 │ 3531.1 │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ āœ… DSD Minimal │ 37.6 │ 37.6 │ 37.6 │ 37.6 │ 1210.7 │ + ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + │ āœ… No DSD Minimal │ 38.7 │ 38.7 │ 38.7 │ 38.7 │ 1210.7 │ + ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + + šŸ“Š Performance Impact: + DSD vs No DSD: 1.29x (DSD slower) + Full vs Minimal: 2.91x speedup with minimal templates +``` + +## Comment Tree Structure + +The generator creates deterministic Reddit-style data: + +```typescript +interface Comment { + id: string; + author: string; + content: string; + score: number; + timestamp: Date; + replies: Comment[]; + depth: number; +} +``` + +### Deterministic Generation + +- **Consistent Content**: Every 10th comment (index % 10 === 0) uses long content for predictable size distribution +- **Fixed Usernames**: Authors selected deterministically based on depth and index +- **Reproducible Scores**: Scores calculated using `(depth * 173 + index * 47) % 1000 - 50` +- **Static Timestamps**: All timestamps relative to 2024-01-01 for consistency +- **Deterministic IDs**: Simple `comment_d{depth}_i{index}` format +- **Predictable Nesting**: Reply patterns based on modulo operations for consistent tree shapes + +This ensures identical template sizes and structures across benchmark runs, making performance comparisons reliable. + +## Performance Considerations + +- **Memory**: Large trees can use significant memory +- **Recursion**: Deep trees test recursive template rendering +- **String Concatenation**: Tests efficiency of HTML generation +- **Template Complexity**: Compares styled vs minimal templates + +## Integration with CI + +The benchmarks can be integrated into CI/CD pipelines: + +```bash +# Run quick balanced test +npm run benchmark:balanced -- --quiet + +# Run with fewer iterations for CI speed +npm run benchmark:shallow-wide -- --iterations=3 --warmup=1 + +# Run all scenarios quickly +npm run benchmark -- --iterations=2 --warmup=1 --quiet +``` + +## Development + +To add new benchmark scenarios: + +1. Create new config in `comment-tree-generator.ts` with the following structure: + ```typescript + new_scenario: { + maxDepth: number, + maxRepliesPerComment: number, + minRepliesPerComment: number, + topLevelComments: number, + iterations: number, // Default iterations for this scenario + warmupIterations: number, // Default warmup iterations + } + ``` +2. Add npm script in `package.json` pointing to the main CLI +3. Update this README + +The CLI automatically detects all scenarios from `COMMENT_TREE_CONFIGS`, so no code changes are needed for new scenarios. + +## Troubleshooting + +**Build Errors**: Ensure TypeScript compilation succeeds with `npm run build:ts` + +**Memory Issues**: Reduce tree size in stress test configurations by modifying `COMMENT_TREE_CONFIGS` + +**Slow Performance**: + +- Check that dependencies are built with `npm run build` +- Reduce iterations for faster testing: `--iterations=3 --warmup=1` +- Use `--quiet` flag to reduce console output overhead + +**Module Resolution Errors**: Ensure you're running from the correct directory and all dependencies are installed diff --git a/packages/labs/ssr/src/benchmarks/cli.ts b/packages/labs/ssr/src/benchmarks/cli.ts new file mode 100644 index 0000000000..4aa0714817 --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/cli.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Command-line interface for running SSR performance benchmarks + */ + +import {runAllBenchmarks, runScenario} from './node-performance-tests.js'; +import {COMMENT_TREE_CONFIGS} from './comment-tree-generator.js'; + +const scenarios = Object.keys(COMMENT_TREE_CONFIGS) as Array; + +async function main() { + const args = process.argv.slice(2); + const scenario = args[0]; + + // Get scenario config for defaults (if scenario is specified and valid) + const scenarioConfig = + scenario && scenarios.includes(scenario) + ? COMMENT_TREE_CONFIGS[scenario as keyof typeof COMMENT_TREE_CONFIGS] + : null; + + // Parse options - use scenario defaults if available, otherwise global defaults + const defaultIterations = scenarioConfig?.iterations ?? 10; + const defaultWarmup = scenarioConfig?.warmupIterations ?? 3; + + const iterations = parseInt( + args.find((arg) => arg.startsWith('--iterations='))?.split('=')[1] ?? + defaultIterations.toString() + ); + const warmup = parseInt( + args.find((arg) => arg.startsWith('--warmup='))?.split('=')[1] ?? + defaultWarmup.toString() + ); + const quiet = args.includes('--quiet'); + + const options = { + iterations, + warmupIterations: warmup, + logProgress: !quiet, + }; + + try { + if (!scenario || scenario === 'all') { + await runAllBenchmarks(options); + } else if (scenarios.includes(scenario)) { + await runScenario(scenario as keyof typeof COMMENT_TREE_CONFIGS, options); + } else { + console.error(`āŒ Unknown scenario: ${scenario}`); + console.error(`Available scenarios: ${scenarios.join(', ')}, all`); + console.error(''); + console.error('Usage:'); + console.error(' npm run benchmark [scenario] [options]'); + console.error(''); + console.error('Options:'); + console.error( + ' --iterations=N Number of test iterations (default: 10)' + ); + console.error( + ' --warmup=N Number of warmup iterations (default: 3)' + ); + console.error(' --quiet Suppress progress output'); + console.error(''); + console.error('Examples:'); + console.error(' npm run benchmark'); + console.error(' npm run benchmark balanced'); + console.error(' npm run benchmark stress_test --iterations=5'); + process.exit(1); + } + } catch (error) { + console.error('āŒ Benchmark failed:', error); + process.exit(1); + } +} + +main(); diff --git a/packages/labs/ssr/src/benchmarks/comment-templates.ts b/packages/labs/ssr/src/benchmarks/comment-templates.ts new file mode 100644 index 0000000000..07508d5530 --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/comment-templates.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {html, type TemplateResult} from 'lit'; +import type {Comment} from './comment-tree-generator.js'; + +/** + * Formats a timestamp for display + */ +function formatTimestamp(timestamp: Date): string { + const now = new Date(); + const diffMs = now.getTime() - timestamp.getTime(); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays}d ago`; + return timestamp.toLocaleDateString(); +} + +/** + * Formats a score with appropriate styling + */ +function formatScore(score: number): string { + if (score >= 1000) { + return `${(score / 1000).toFixed(1)}k`; + } + return score.toString(); +} + +/** + * Renders a single comment without replies + */ +export function renderComment(comment: Comment): TemplateResult { + return html` +
+
+ ${comment.author} + + ${formatScore(comment.score)} + + ${formatTimestamp(comment.timestamp)} +
+
+ ${comment.content + .split('\n\n') + .map((paragraph) => html`

${paragraph.trim()}

`)} +
+
+ + + + + + ${comment.replies.length > 0 + ? html` + + ` + : ''} +
+
+ `; +} + +/** + * Recursively renders a comment and all its replies + */ +export function renderCommentWithReplies(comment: Comment): TemplateResult { + return html` + ${renderComment(comment)} + ${comment.replies.length > 0 + ? html` +
+ ${comment.replies.map((reply) => renderCommentWithReplies(reply))} +
+ ` + : ''} + `; +} + +/** + * Renders a complete comment thread + */ +export function renderCommentThread(comments: Comment[]): TemplateResult { + return html` +
+ + +
+ ${comments.map((comment) => renderCommentWithReplies(comment))} +
+
+ `; +} + +/** + * Renders a simplified version for performance testing (no styling) + */ +export function renderCommentThreadMinimal( + comments: Comment[] +): TemplateResult { + return html` +
+ ${comments.map((comment) => renderCommentMinimal(comment))} +
+ `; +} + +/** + * Minimal comment rendering for performance testing + */ +function renderCommentMinimal(comment: Comment): TemplateResult { + return html` +
+
+ ${comment.author} • ${formatScore(comment.score)} • + ${formatTimestamp(comment.timestamp)} +
+ ${comment.content} + ${comment.replies.length > 0 + ? html` + + ${comment.replies.map((reply) => renderCommentMinimal(reply))} + + ` + : ''} +
+ `; +} + +/** + * Renders comment statistics for benchmarking info + */ +export function renderBenchmarkInfo(stats: { + totalComments: number; + maxDepth: number; + avgRepliesPerComment: number; + totalCharacters: number; + renderTime?: number; + templateSize?: number; +}): TemplateResult { + return html` +
+

Benchmark Statistics

+
    +
  • Total Comments: ${stats.totalComments}
  • +
  • Max Depth: ${stats.maxDepth}
  • +
  • + Avg Replies per Comment: ${stats.avgRepliesPerComment.toFixed(2)} +
  • +
  • Total Characters: ${stats.totalCharacters.toLocaleString()}
  • + ${stats.renderTime + ? html`
  • Render Time: ${stats.renderTime}ms
  • ` + : ''} + ${stats.templateSize + ? html`
  • + Template Size: ${(stats.templateSize / 1024).toFixed(2)}KB +
  • ` + : ''} +
+
+ `; +} diff --git a/packages/labs/ssr/src/benchmarks/comment-tree-generator.ts b/packages/labs/ssr/src/benchmarks/comment-tree-generator.ts new file mode 100644 index 0000000000..6fb2fb58cf --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/comment-tree-generator.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Data structure representing a Reddit-style comment + */ +export interface Comment { + id: string; + author: string; + content: string; + score: number; + timestamp: Date; + replies: Comment[]; + depth: number; +} + +/** + * Configuration for generating comment trees + */ +export interface CommentTreeConfig { + /** Maximum depth of comment nesting */ + maxDepth: number; + /** Maximum number of replies at each level */ + maxRepliesPerComment: number; + /** Minimum number of replies at each level */ + minRepliesPerComment: number; + /** Total number of top-level comments */ + topLevelComments: number; + /** Default number of test iterations for this scenario */ + iterations: number; + /** Default number of warmup iterations for this scenario */ + warmupIterations: number; +} + +/** + * Predefined configurations for different test scenarios + */ +export const COMMENT_TREE_CONFIGS = { + shallow_wide: { + maxDepth: 3, + maxRepliesPerComment: 20, + minRepliesPerComment: 5, + topLevelComments: 50, + iterations: 10, + warmupIterations: 3, + }, + deep_narrow: { + maxDepth: 15, + maxRepliesPerComment: 3, + minRepliesPerComment: 1, + topLevelComments: 10, + iterations: 15, + warmupIterations: 5, + }, + balanced: { + maxDepth: 8, + maxRepliesPerComment: 8, + minRepliesPerComment: 2, + topLevelComments: 25, + iterations: 10, + warmupIterations: 3, + }, + reddit_frontpage: { + maxDepth: 12, + maxRepliesPerComment: 15, + minRepliesPerComment: 0, + topLevelComments: 100, + iterations: 8, + warmupIterations: 2, + }, + stress_test: { + maxDepth: 20, + maxRepliesPerComment: 50, + minRepliesPerComment: 10, + topLevelComments: 200, + iterations: 5, + warmupIterations: 2, + }, +} as const satisfies Record; + +/** + * Sample content for generating realistic comments + */ +const SAMPLE_CONTENT = [ + 'This is an interesting point. I never thought about it that way.', + "I disagree with the premise here. The data doesn't support this conclusion.", + 'Great explanation! This really helps clarify the topic.', + 'Can you provide a source for this claim?', + 'TL;DR: The main takeaway is that performance matters a lot in web development.', + 'Edit: Thanks for the gold, kind stranger!', + 'This. So much this.', + 'Came here to say exactly this.', + "I'm a software engineer with 15 years of experience, and I can confirm this is accurate.", + 'As someone who works in this field, I can tell you that the reality is much more complex.', + 'Your mileage may vary, but in my experience this approach works well.', + "Here's a relevant XKCD: https://xkcd.com/927/", + 'Username checks out.', + 'Instructions unclear, got my head stuck in the ceiling fan.', + '5/7 perfect score', + 'This deserves more upvotes.', + 'Underrated comment right here.', + "I'm not crying, you're crying.", + 'Take my upvote and leave.', + 'The real life pro tip is always in the comments.', +]; + +const SAMPLE_LONG_CONTENT = [ + `This is a much longer comment that goes into great detail about the subject matter. It includes multiple paragraphs with various points of view and extensive analysis of the topic at hand. + + The first paragraph introduces the main concept and provides necessary background information. This helps readers understand the context and importance of the discussion. + + In the second paragraph, we dive deeper into the technical aspects and explore various implications. This is where the real meat of the argument begins to take shape and we can see how different factors interact with each other. + + Finally, the conclusion ties everything together and provides actionable insights that readers can apply to their own situations. This comprehensive approach ensures that all aspects of the topic have been thoroughly covered.`, + + `I've been following this discussion for a while now, and I wanted to share some insights from my professional experience in this field. Having worked on similar projects for over a decade, I've seen how these patterns emerge and evolve over time. + + The key insight here is that performance optimization is rarely about finding a single silver bullet solution. Instead, it's about understanding the trade-offs and making informed decisions based on your specific use case and constraints. + + For example, when dealing with large datasets in server-side rendering, you need to consider not just the initial render time, but also memory usage, caching strategies, and how the solution will scale as your user base grows.`, + + `Let me break down the technical details for anyone who's interested in the implementation specifics: + + 1. **Data Structure Optimization**: The key is using efficient data structures that minimize memory allocation and provide fast lookups. + + 2. **Rendering Strategy**: Server-side rendering with proper streaming can significantly improve perceived performance. + + 3. **Caching Layer**: Implementing intelligent caching at multiple levels (component, page, and CDN) is crucial for scale. + + 4. **Measurement and Monitoring**: You can't optimize what you don't measure - proper benchmarking and real-user monitoring are essential. + + Each of these areas has its own complexities and nuances that require careful consideration and testing.`, +]; + +const USERNAMES = [ + 'dev_guru_42', + 'code_ninja', + 'react_fanboy', + 'vue_enthusiast', + 'angular_architect', + 'fullstack_wizard', + 'frontend_master', + 'backend_beast', + 'database_destroyer', + 'algorithm_ace', + 'performance_prophet', + 'bug_hunter', + 'refactor_king', + 'test_driven_dev', + 'agile_advocate', + 'devops_deity', + 'security_specialist', + 'ui_ux_unicorn', + 'api_architect', + 'cloud_captain', + 'mobile_maverick', + 'game_dev_god', + 'data_scientist', + 'ml_engineer', + 'blockchain_believer', +]; + +/** + * Generates a single comment with deterministic data based on depth and index + */ +function generateComment(depth: number, index: number): Comment { + // Deterministic long content: every 10th comment is long (index % 10 === 0) + const isLongContent = index % 10 === 0; + + // Deterministic content selection based on depth and index + const contentSeed = + (depth * 1000 + index) % + (isLongContent ? SAMPLE_LONG_CONTENT.length : SAMPLE_CONTENT.length); + const content = isLongContent + ? SAMPLE_LONG_CONTENT[contentSeed] + : SAMPLE_CONTENT[contentSeed]; + + // Deterministic username selection + const usernameSeed = (depth * 100 + index) % USERNAMES.length; + const author = USERNAMES[usernameSeed]; + + // Deterministic score based on depth and index (-50 to 949 range) + const score = ((depth * 173 + index * 47) % 1000) - 50; + + // Fixed timestamp (2024-01-01 minus deterministic days) + const baseDays = (depth * 7 + index * 3) % 30; // 0-29 days ago + const timestamp = new Date('2024-01-01'); + timestamp.setDate(timestamp.getDate() - baseDays); + + return { + id: `comment_d${depth}_i${index}`, + author, + content, + score, + timestamp, + replies: [], + depth, + }; +} + +/** + * Recursively generates a comment tree with deterministic structure + */ +function generateCommentTree( + depth: number, + config: CommentTreeConfig, + parentIndex = 0 +): Comment[] { + if (depth >= config.maxDepth) { + return []; + } + + // Deterministic number of comments at each level + const numComments = + depth === 0 + ? config.topLevelComments + : Math.min( + config.maxRepliesPerComment, + Math.max( + config.minRepliesPerComment, + // Use a deterministic pattern: reduce replies as depth increases + Math.floor(config.maxRepliesPerComment * Math.pow(0.8, depth)) + + (parentIndex % 3) + ) + ); + + const comments: Comment[] = []; + + for (let i = 0; i < numComments; i++) { + const comment = generateComment(depth, i); + + // Deterministic reply generation: create replies based on patterns + // Comments with even index at shallow depths get more replies + const shouldHaveReplies = + depth < config.maxDepth - 1 && // Not at max depth - 1 + ((depth === 0 && i % 2 === 0) || // Top level: every other comment + (depth === 1 && i % 3 === 0) || // Depth 1: every third comment + (depth === 2 && i % 4 === 0) || // Depth 2: every fourth comment + (depth >= 3 && i === 0)); // Deeper: only first comment + + if (shouldHaveReplies) { + comment.replies = generateCommentTree(depth + 1, config, i); + } + + comments.push(comment); + } + + return comments; +} + +/** + * Generates a complete comment tree with deterministic structure based on configuration + * Results are reproducible - the same config will always generate identical trees + */ +export function generateCommentTreeData(config: CommentTreeConfig): Comment[] { + return generateCommentTree(0, config); +} + +/** + * Calculates statistics about a comment tree + */ +export interface CommentTreeStats { + totalComments: number; + maxDepth: number; + avgRepliesPerComment: number; + totalCharacters: number; +} + +/** + * Analyzes a comment tree and returns statistics + */ +export function analyzeCommentTree(comments: Comment[]): CommentTreeStats { + let totalComments = 0; + let maxDepth = 0; + let totalReplies = 0; + let totalCharacters = 0; + let commentsWithReplies = 0; + + function traverse(comments: Comment[], currentDepth = 0) { + for (const comment of comments) { + totalComments++; + totalCharacters += comment.content.length; + maxDepth = Math.max(maxDepth, currentDepth); + + if (comment.replies.length > 0) { + totalReplies += comment.replies.length; + commentsWithReplies++; + traverse(comment.replies, currentDepth + 1); + } + } + } + + traverse(comments); + + return { + totalComments, + maxDepth, + avgRepliesPerComment: + commentsWithReplies > 0 ? totalReplies / commentsWithReplies : 0, + totalCharacters, + }; +} diff --git a/packages/labs/ssr/src/benchmarks/index.ts b/packages/labs/ssr/src/benchmarks/index.ts new file mode 100644 index 0000000000..c90c01e456 --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * SSR Performance Benchmarks with Node.js + * + * This module provides Node.js-based performance tests for Lit SSR using Reddit-style + * comment trees to simulate deeply nested and wide content structures. + */ + +// Re-export all test functions and utilities +export * from './comment-tree-generator.js'; +export * from './comment-templates.js'; +export * from './node-performance-tests.js'; diff --git a/packages/labs/ssr/src/benchmarks/node-performance-tests.ts b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts new file mode 100644 index 0000000000..f56184eec0 --- /dev/null +++ b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts @@ -0,0 +1,454 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Node.js-based performance tests for Lit SSR using Reddit-style comment trees + * This replaces k6 tests since k6 cannot handle Node.js modules like 'lit' + */ + +import {performance} from 'perf_hooks'; +import {render} from '../lib/render.js'; +import { + generateCommentTreeData, + analyzeCommentTree, + COMMENT_TREE_CONFIGS, + type CommentTreeConfig, + type Comment, +} from './comment-tree-generator.js'; +import { + renderCommentThread, + renderCommentThreadMinimal, +} from './comment-templates.js'; + +interface BenchmarkResult { + variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal'; + totalComments: number; + maxDepth: number; + renderTime: number; + templateSize: number; + avgRepliesPerComment: number; + totalCharacters: number; + success: boolean; + error?: string; +} + +interface ScenarioResults { + scenario: string; + results: BenchmarkResult[]; + stats: { + dsd: VariantStats; + 'no-dsd': VariantStats; + 'dsd-minimal': VariantStats; + 'no-dsd-minimal': VariantStats; + }; +} + +interface VariantStats { + avgRenderTime: number; + minRenderTime: number; + maxRenderTime: number; + p75RenderTime: number; + p90RenderTime: number; + p95RenderTime: number; + successRate: number; + avgTemplateSize: number; +} + +interface BenchmarkOptions { + iterations: number; + warmupIterations: number; + logProgress: boolean; +} + +const DEFAULT_OPTIONS: BenchmarkOptions = { + iterations: 10, + warmupIterations: 3, + logProgress: true, +}; + +const LIT_SSR_OPTIONS = { + elementRenderers: [], + customElementInstanceStack: [], + customElementHostStack: [], + deferHydration: false, +}; + +/** + * Renders a template to string using SSR and measures performance + */ +function renderTemplateToString( + template: unknown, + disableDsd = false +): {html: string; duration: number; size: number} { + const startTime = performance.now(); + + try { + const renderResult = disableDsd + ? render(template, LIT_SSR_OPTIONS) + : render(template); + let html = ''; + + // Iterate through the generator to get all HTML chunks + for (const chunk of renderResult) { + html += chunk; + } + + const duration = performance.now() - startTime; + const size = html.length; + + return {html, duration, size}; + } catch (error) { + console.error('Render error:', error); + throw error; + } +} + +/** + * Validates rendered HTML for basic correctness + */ +function validateHtml(html: string): boolean { + if (html.length === 0) return false; + + // Check for comment indicators + if (!html.includes('data-comment-id') && !html.includes('data-id')) + return false; + + // Basic check for balanced div tags + const openDivs = (html.match(/
/g) || []).length; + return openDivs === closeDivs; +} + +/** + * Runs a single benchmark iteration for a specific variant + */ +function runSingleBenchmark( + variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal', + comments: Comment[] +): BenchmarkResult { + const stats = analyzeCommentTree(comments); + + const isMinimal = variant.includes('-minimal'); + const disableDsd = variant.includes('no-dsd'); + + // Choose rendering template based on variant + const template = isMinimal + ? renderCommentThreadMinimal(comments) + : renderCommentThread(comments); + + try { + const {html, duration, size} = renderTemplateToString(template, disableDsd); + const isValid = validateHtml(html); + + return { + variant, + totalComments: stats.totalComments, + maxDepth: stats.maxDepth, + renderTime: duration, + templateSize: size, + avgRepliesPerComment: stats.avgRepliesPerComment, + totalCharacters: stats.totalCharacters, + success: isValid, + }; + } catch (error) { + return { + variant, + totalComments: stats.totalComments, + maxDepth: stats.maxDepth, + renderTime: 0, + templateSize: 0, + avgRepliesPerComment: stats.avgRepliesPerComment, + totalCharacters: stats.totalCharacters, + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Calculates statistics for a set of benchmark results + */ +function calculateVariantStats(results: BenchmarkResult[]): VariantStats { + const renderTimes = results.filter((r) => r.success).map((r) => r.renderTime); + const sortedTimes = [...renderTimes].sort((a, b) => a - b); + const p75Index = Math.floor(sortedTimes.length * 0.75); + const p90Index = Math.floor(sortedTimes.length * 0.9); + const p95Index = Math.floor(sortedTimes.length * 0.95); + + return { + avgRenderTime: + renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length || 0, + minRenderTime: Math.min(...renderTimes) || 0, + maxRenderTime: Math.max(...renderTimes) || 0, + p75RenderTime: sortedTimes[p75Index] || 0, + p90RenderTime: sortedTimes[p90Index] || 0, + p95RenderTime: sortedTimes[p95Index] || 0, + successRate: results.filter((r) => r.success).length / results.length, + avgTemplateSize: + results + .filter((r) => r.success) + .reduce((sum, r) => sum + r.templateSize, 0) / + results.filter((r) => r.success).length || 0, + }; +} + +/** + * Runs multiple iterations of a benchmark and returns statistics for all 4 variants + */ +function runBenchmarkSuite( + scenario: string, + config: CommentTreeConfig, + options: Partial = {} +): ScenarioResults { + // Use scenario-specific defaults, then global defaults, then provided options + const scenarioDefaults = { + iterations: config.iterations, + warmupIterations: config.warmupIterations, + logProgress: DEFAULT_OPTIONS.logProgress, + }; + const opts = {...scenarioDefaults, ...options}; + const results: BenchmarkResult[] = []; + + // Generate test data once + if (opts.logProgress) { + console.log(`\nšŸ“Š Starting ${scenario} benchmark...`); + console.log(`Generating test data...`); + } + + const comments = generateCommentTreeData(config); + const stats = analyzeCommentTree(comments); + + if (opts.logProgress) { + console.log( + `šŸ“ˆ Test data: ${stats.totalComments} comments, max depth ${stats.maxDepth}` + ); + console.log( + `šŸ”„ Running ${opts.warmupIterations} warmup + ${opts.iterations} test iterations (4 variants each)...` + ); + } + + const variants: Array<'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal'> = [ + 'dsd', + 'no-dsd', + 'dsd-minimal', + 'no-dsd-minimal', + ]; + + // Warmup iterations - one for each variant + for (let i = 0; i < opts.warmupIterations; i++) { + for (const variant of variants) { + runSingleBenchmark(variant, comments); + } + if ( + opts.logProgress && + (i + 1) % Math.max(1, Math.floor(opts.warmupIterations / 3)) === 0 + ) { + process.stdout.write('šŸ”„'); + } + } + + if (opts.logProgress && opts.warmupIterations > 0) { + console.log(' warmup complete'); + } + + // Test iterations - all 4 variants per iteration + for (let i = 0; i < opts.iterations; i++) { + for (const variant of variants) { + const result = runSingleBenchmark(variant, comments); + results.push(result); + } + + if ( + opts.logProgress && + (i + 1) % Math.max(1, Math.floor(opts.iterations / 10)) === 0 + ) { + process.stdout.write('āœ…'); + } + } + + if (opts.logProgress) { + console.log(' benchmarks complete'); + } + + // Group results by variant and calculate stats + const variantResults = { + dsd: results.filter((r) => r.variant === 'dsd'), + 'no-dsd': results.filter((r) => r.variant === 'no-dsd'), + 'dsd-minimal': results.filter((r) => r.variant === 'dsd-minimal'), + 'no-dsd-minimal': results.filter((r) => r.variant === 'no-dsd-minimal'), + }; + + return { + scenario, + results, + stats: { + dsd: calculateVariantStats(variantResults.dsd), + 'no-dsd': calculateVariantStats(variantResults['no-dsd']), + 'dsd-minimal': calculateVariantStats(variantResults['dsd-minimal']), + 'no-dsd-minimal': calculateVariantStats(variantResults['no-dsd-minimal']), + }, + }; +} + +/** + * Formats benchmark results for display in a table + */ +function formatResults(scenario: string, benchmarkData: ScenarioResults): void { + const {stats} = benchmarkData; + + console.log(`\nšŸŽÆ Results for ${scenario.toUpperCase()}:`); + + // Calculate iterations per variant (should be the same for all) + const iterationsPerVariant = benchmarkData.results.filter( + (r) => r.variant === 'dsd' + ).length; + console.log(` Iterations: ${iterationsPerVariant} per variant\n`); + + // Table header - sized to fit " āœ… No DSD Minimal " (longest content + space) + console.log( + ' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”' + ); + console.log( + ' │ Variant │ Avg (ms) │ p75 (ms) │ p90 (ms) │ p95 (ms) │ Size (KB) │' + ); + console.log( + ' ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤' + ); + + // Table rows + const variants: Array<{key: keyof typeof stats; label: string}> = [ + {key: 'dsd', label: 'DSD'}, + {key: 'no-dsd', label: 'No DSD'}, + {key: 'dsd-minimal', label: 'DSD Minimal'}, + {key: 'no-dsd-minimal', label: 'No DSD Minimal'}, + ]; + + variants.forEach((variant, index) => { + const variantStats = stats[variant.key]; + + // Format values to match exact header spacing - account for emoji width (emojis = 2 chars) + const status = variantStats.successRate >= 0.95 ? 'āœ…' : 'āŒ'; + + // Build first column with proper spacing for longest content + // " āœ… No DSD Minimal " needs 18 JS chars (19 terminal chars with emoji) + const baseText = ` ${status} ${variant.label}`; + const col1 = baseText.padEnd(18, ' '); // 18 JS chars = 19 terminal chars with emoji + const col2 = ` ${variantStats.avgRenderTime.toFixed(1)}`.padEnd(14, ' '); // " 132.3 " + const col3 = ` ${variantStats.p75RenderTime.toFixed(1)}`.padEnd(13, ' '); // " 132.3 " + const col4 = ` ${variantStats.p90RenderTime.toFixed(1)}`.padEnd(13, ' '); // " 132.3 " + const col5 = ` ${variantStats.p95RenderTime.toFixed(1)}`.padEnd(13, ' '); // " 132.3 " + const col6 = ` ${(variantStats.avgTemplateSize / 1024).toFixed(1)}`.padEnd( + 14, + ' ' + ); // " 3531.1 " + + console.log(` │${col1}│${col2}│${col3}│${col4}│${col5}│${col6}│`); + + if (index < variants.length - 1) { + console.log( + ' ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤' + ); + } + }); + + console.log( + ' ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜' + ); + + // Performance comparisons + const dsdVsNoDsd = stats.dsd.avgRenderTime / stats['no-dsd'].avgRenderTime; + const fullVsMinimal = + ((stats.dsd.avgRenderTime + stats['no-dsd'].avgRenderTime) / + 2 / + (stats['dsd-minimal'].avgRenderTime + + stats['no-dsd-minimal'].avgRenderTime)) * + 2; + + console.log(`\n šŸ“Š Performance Impact:`); + console.log( + ` DSD vs No DSD: ${dsdVsNoDsd.toFixed(2)}x (${dsdVsNoDsd > 1 ? 'DSD slower' : 'DSD faster'})` + ); + console.log( + ` Full vs Minimal: ${fullVsMinimal.toFixed(2)}x speedup with minimal templates` + ); +} + +/** + * Runs all benchmark scenarios + */ +export async function runAllBenchmarks( + options: Partial = {} +): Promise { + console.log('šŸš€ Starting Lit SSR Performance Benchmarks'); + console.log('=========================================='); + + const scenarios = [ + {name: 'shallow-wide', config: COMMENT_TREE_CONFIGS.shallow_wide}, + {name: 'deep-narrow', config: COMMENT_TREE_CONFIGS.deep_narrow}, + {name: 'balanced', config: COMMENT_TREE_CONFIGS.balanced}, + {name: 'reddit-frontpage', config: COMMENT_TREE_CONFIGS.reddit_frontpage}, + {name: 'stress-test', config: COMMENT_TREE_CONFIGS.stress_test}, + ]; + + const allResults: Array<{scenario: string; data: ScenarioResults}> = []; + + for (const scenario of scenarios) { + const data = runBenchmarkSuite(scenario.name, scenario.config, options); + formatResults(scenario.name, data); + allResults.push({scenario: scenario.name, data}); + } + + // Summary table + console.log('\nšŸ“‹ BENCHMARK SUMMARY'); + console.log('===================='); + console.log( + 'Scenario │ DSD Avg │ No DSD Avg │ DSD Min Avg │ No DSD Min Avg │' + ); + console.log( + '─────────────────┼────────────┼────────────┼─────────────┼────────────────┤' + ); + + allResults.forEach(({scenario, data}) => { + const dsdAvg = data.stats.dsd.avgRenderTime.toFixed(1).padStart(6); + const noDsdAvg = data.stats['no-dsd'].avgRenderTime.toFixed(1).padStart(7); + const dsdMinAvg = data.stats['dsd-minimal'].avgRenderTime + .toFixed(1) + .padStart(7); + const noDsdMinAvg = data.stats['no-dsd-minimal'].avgRenderTime + .toFixed(1) + .padStart(10); + + const allSuccess = Object.values(data.stats).every( + (s) => s.successRate >= 0.95 + ); + const status = allSuccess ? 'āœ…' : 'āŒ'; + + console.log( + `${status} ${scenario.padEnd(13)} │ ${dsdAvg}ms │ ${noDsdAvg}ms │ ${dsdMinAvg}ms │ ${noDsdMinAvg}ms │` + ); + }); + + console.log('\nšŸŽ‰ All benchmarks completed!'); +} + +/** + * Runs a specific benchmark scenario + */ +export async function runScenario( + scenarioName: keyof typeof COMMENT_TREE_CONFIGS, + options: Partial = {} +): Promise { + const config = COMMENT_TREE_CONFIGS[scenarioName]; + if (!config) { + throw new Error(`Unknown scenario: ${scenarioName}`); + } + + console.log(`šŸŽÆ Running ${scenarioName} benchmark`); + console.log('='.repeat(30 + scenarioName.length)); + + const data = runBenchmarkSuite(scenarioName, config, options); + formatResults(scenarioName, data); +} From 0f4a92e23cb6354866785fc4f70d8d5820e3226c Mon Sep 17 00:00:00 2001 From: Jim Simon Date: Mon, 22 Sep 2025 18:08:36 -0400 Subject: [PATCH 2/6] Remove deoptimizing try-catch and ensure stress_test can run --- .../src/benchmarks/node-performance-tests.ts | 76 +++++++++---------- 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/packages/labs/ssr/src/benchmarks/node-performance-tests.ts b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts index f56184eec0..6b8c7ebecd 100644 --- a/packages/labs/ssr/src/benchmarks/node-performance-tests.ts +++ b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts @@ -22,6 +22,7 @@ import { renderCommentThread, renderCommentThreadMinimal, } from './comment-templates.js'; +import {type RenderResult} from '../lib/render-result.js'; interface BenchmarkResult { variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal'; @@ -79,56 +80,43 @@ const LIT_SSR_OPTIONS = { /** * Renders a template to string using SSR and measures performance */ -function renderTemplateToString( +async function renderTemplateToString( template: unknown, disableDsd = false -): {html: string; duration: number; size: number} { +): Promise<{duration: number; size: number}> { const startTime = performance.now(); - try { - const renderResult = disableDsd - ? render(template, LIT_SSR_OPTIONS) - : render(template); - let html = ''; - - // Iterate through the generator to get all HTML chunks - for (const chunk of renderResult) { - html += chunk; - } - - const duration = performance.now() - startTime; - const size = html.length; + const renderResult = disableDsd + ? render(template, LIT_SSR_OPTIONS) + : render(template); + let size = 0; - return {html, duration, size}; - } catch (error) { - console.error('Render error:', error); - throw error; + // Iterate through the generator to get all HTML chunks + for (const chunk of renderResult) { + size += await countResult(await chunk); } -} -/** - * Validates rendered HTML for basic correctness - */ -function validateHtml(html: string): boolean { - if (html.length === 0) return false; + const duration = performance.now() - startTime; - // Check for comment indicators - if (!html.includes('data-comment-id') && !html.includes('data-id')) - return false; + return {duration, size}; +} - // Basic check for balanced div tags - const openDivs = (html.match(/
/g) || []).length; - return openDivs === closeDivs; +async function countResult(result: RenderResult): Promise { + let count = 0; + for (const chunk of result) { + count += + typeof chunk === 'string' ? chunk.length : await countResult(await chunk); + } + return count; } /** * Runs a single benchmark iteration for a specific variant */ -function runSingleBenchmark( +async function runSingleBenchmark( variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal', comments: Comment[] -): BenchmarkResult { +): Promise { const stats = analyzeCommentTree(comments); const isMinimal = variant.includes('-minimal'); @@ -140,8 +128,8 @@ function runSingleBenchmark( : renderCommentThread(comments); try { - const {html, duration, size} = renderTemplateToString(template, disableDsd); - const isValid = validateHtml(html); + const {duration, size} = await renderTemplateToString(template, disableDsd); + const isValid = size > 0; return { variant, @@ -198,11 +186,11 @@ function calculateVariantStats(results: BenchmarkResult[]): VariantStats { /** * Runs multiple iterations of a benchmark and returns statistics for all 4 variants */ -function runBenchmarkSuite( +async function runBenchmarkSuite( scenario: string, config: CommentTreeConfig, options: Partial = {} -): ScenarioResults { +): Promise { // Use scenario-specific defaults, then global defaults, then provided options const scenarioDefaults = { iterations: config.iterations, @@ -240,7 +228,7 @@ function runBenchmarkSuite( // Warmup iterations - one for each variant for (let i = 0; i < opts.warmupIterations; i++) { for (const variant of variants) { - runSingleBenchmark(variant, comments); + await runSingleBenchmark(variant, comments); } if ( opts.logProgress && @@ -257,7 +245,7 @@ function runBenchmarkSuite( // Test iterations - all 4 variants per iteration for (let i = 0; i < opts.iterations; i++) { for (const variant of variants) { - const result = runSingleBenchmark(variant, comments); + const result = await runSingleBenchmark(variant, comments); results.push(result); } @@ -396,7 +384,11 @@ export async function runAllBenchmarks( const allResults: Array<{scenario: string; data: ScenarioResults}> = []; for (const scenario of scenarios) { - const data = runBenchmarkSuite(scenario.name, scenario.config, options); + const data = await runBenchmarkSuite( + scenario.name, + scenario.config, + options + ); formatResults(scenario.name, data); allResults.push({scenario: scenario.name, data}); } @@ -449,6 +441,6 @@ export async function runScenario( console.log(`šŸŽÆ Running ${scenarioName} benchmark`); console.log('='.repeat(30 + scenarioName.length)); - const data = runBenchmarkSuite(scenarioName, config, options); + const data = await runBenchmarkSuite(scenarioName, config, options); formatResults(scenarioName, data); } From 155b67e35d16d8680114c06e0ac17df7a3f942e8 Mon Sep 17 00:00:00 2001 From: Jim Simon Date: Tue, 23 Sep 2025 16:01:42 -0400 Subject: [PATCH 3/6] Remove minimal variant --- .../ssr/src/benchmarks/comment-templates.ts | 35 ------------ .../src/benchmarks/node-performance-tests.ts | 56 +++---------------- 2 files changed, 8 insertions(+), 83 deletions(-) diff --git a/packages/labs/ssr/src/benchmarks/comment-templates.ts b/packages/labs/ssr/src/benchmarks/comment-templates.ts index 07508d5530..5f83ba4bc7 100644 --- a/packages/labs/ssr/src/benchmarks/comment-templates.ts +++ b/packages/labs/ssr/src/benchmarks/comment-templates.ts @@ -219,41 +219,6 @@ export function renderCommentThread(comments: Comment[]): TemplateResult { `; } -/** - * Renders a simplified version for performance testing (no styling) - */ -export function renderCommentThreadMinimal( - comments: Comment[] -): TemplateResult { - return html` -
- ${comments.map((comment) => renderCommentMinimal(comment))} -
- `; -} - -/** - * Minimal comment rendering for performance testing - */ -function renderCommentMinimal(comment: Comment): TemplateResult { - return html` -
-
- ${comment.author} • ${formatScore(comment.score)} • - ${formatTimestamp(comment.timestamp)} -
- ${comment.content} - ${comment.replies.length > 0 - ? html` - - ${comment.replies.map((reply) => renderCommentMinimal(reply))} - - ` - : ''} -
- `; -} - /** * Renders comment statistics for benchmarking info */ diff --git a/packages/labs/ssr/src/benchmarks/node-performance-tests.ts b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts index 6b8c7ebecd..d4cc18f50b 100644 --- a/packages/labs/ssr/src/benchmarks/node-performance-tests.ts +++ b/packages/labs/ssr/src/benchmarks/node-performance-tests.ts @@ -18,14 +18,11 @@ import { type CommentTreeConfig, type Comment, } from './comment-tree-generator.js'; -import { - renderCommentThread, - renderCommentThreadMinimal, -} from './comment-templates.js'; +import {renderCommentThread} from './comment-templates.js'; import {type RenderResult} from '../lib/render-result.js'; interface BenchmarkResult { - variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal'; + variant: 'dsd' | 'no-dsd'; totalComments: number; maxDepth: number; renderTime: number; @@ -42,8 +39,6 @@ interface ScenarioResults { stats: { dsd: VariantStats; 'no-dsd': VariantStats; - 'dsd-minimal': VariantStats; - 'no-dsd-minimal': VariantStats; }; } @@ -114,18 +109,15 @@ async function countResult(result: RenderResult): Promise { * Runs a single benchmark iteration for a specific variant */ async function runSingleBenchmark( - variant: 'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal', + variant: 'dsd' | 'no-dsd', comments: Comment[] ): Promise { const stats = analyzeCommentTree(comments); - const isMinimal = variant.includes('-minimal'); const disableDsd = variant.includes('no-dsd'); // Choose rendering template based on variant - const template = isMinimal - ? renderCommentThreadMinimal(comments) - : renderCommentThread(comments); + const template = renderCommentThread(comments); try { const {duration, size} = await renderTemplateToString(template, disableDsd); @@ -218,12 +210,7 @@ async function runBenchmarkSuite( ); } - const variants: Array<'dsd' | 'no-dsd' | 'dsd-minimal' | 'no-dsd-minimal'> = [ - 'dsd', - 'no-dsd', - 'dsd-minimal', - 'no-dsd-minimal', - ]; + const variants: Array<'dsd' | 'no-dsd'> = ['dsd', 'no-dsd']; // Warmup iterations - one for each variant for (let i = 0; i < opts.warmupIterations; i++) { @@ -265,8 +252,6 @@ async function runBenchmarkSuite( const variantResults = { dsd: results.filter((r) => r.variant === 'dsd'), 'no-dsd': results.filter((r) => r.variant === 'no-dsd'), - 'dsd-minimal': results.filter((r) => r.variant === 'dsd-minimal'), - 'no-dsd-minimal': results.filter((r) => r.variant === 'no-dsd-minimal'), }; return { @@ -275,8 +260,6 @@ async function runBenchmarkSuite( stats: { dsd: calculateVariantStats(variantResults.dsd), 'no-dsd': calculateVariantStats(variantResults['no-dsd']), - 'dsd-minimal': calculateVariantStats(variantResults['dsd-minimal']), - 'no-dsd-minimal': calculateVariantStats(variantResults['no-dsd-minimal']), }, }; } @@ -295,7 +278,6 @@ function formatResults(scenario: string, benchmarkData: ScenarioResults): void { ).length; console.log(` Iterations: ${iterationsPerVariant} per variant\n`); - // Table header - sized to fit " āœ… No DSD Minimal " (longest content + space) console.log( ' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”' ); @@ -310,8 +292,6 @@ function formatResults(scenario: string, benchmarkData: ScenarioResults): void { const variants: Array<{key: keyof typeof stats; label: string}> = [ {key: 'dsd', label: 'DSD'}, {key: 'no-dsd', label: 'No DSD'}, - {key: 'dsd-minimal', label: 'DSD Minimal'}, - {key: 'no-dsd-minimal', label: 'No DSD Minimal'}, ]; variants.forEach((variant, index) => { @@ -321,7 +301,6 @@ function formatResults(scenario: string, benchmarkData: ScenarioResults): void { const status = variantStats.successRate >= 0.95 ? 'āœ…' : 'āŒ'; // Build first column with proper spacing for longest content - // " āœ… No DSD Minimal " needs 18 JS chars (19 terminal chars with emoji) const baseText = ` ${status} ${variant.label}`; const col1 = baseText.padEnd(18, ' '); // 18 JS chars = 19 terminal chars with emoji const col2 = ` ${variantStats.avgRenderTime.toFixed(1)}`.padEnd(14, ' '); // " 132.3 " @@ -348,20 +327,11 @@ function formatResults(scenario: string, benchmarkData: ScenarioResults): void { // Performance comparisons const dsdVsNoDsd = stats.dsd.avgRenderTime / stats['no-dsd'].avgRenderTime; - const fullVsMinimal = - ((stats.dsd.avgRenderTime + stats['no-dsd'].avgRenderTime) / - 2 / - (stats['dsd-minimal'].avgRenderTime + - stats['no-dsd-minimal'].avgRenderTime)) * - 2; console.log(`\n šŸ“Š Performance Impact:`); console.log( ` DSD vs No DSD: ${dsdVsNoDsd.toFixed(2)}x (${dsdVsNoDsd > 1 ? 'DSD slower' : 'DSD faster'})` ); - console.log( - ` Full vs Minimal: ${fullVsMinimal.toFixed(2)}x speedup with minimal templates` - ); } /** @@ -396,22 +366,12 @@ export async function runAllBenchmarks( // Summary table console.log('\nšŸ“‹ BENCHMARK SUMMARY'); console.log('===================='); - console.log( - 'Scenario │ DSD Avg │ No DSD Avg │ DSD Min Avg │ No DSD Min Avg │' - ); - console.log( - '─────────────────┼────────────┼────────────┼─────────────┼────────────────┤' - ); + console.log('Scenario │ DSD Avg │ No DSD Avg │'); + console.log('─────────────────┼────────────┼────────────┤'); allResults.forEach(({scenario, data}) => { const dsdAvg = data.stats.dsd.avgRenderTime.toFixed(1).padStart(6); const noDsdAvg = data.stats['no-dsd'].avgRenderTime.toFixed(1).padStart(7); - const dsdMinAvg = data.stats['dsd-minimal'].avgRenderTime - .toFixed(1) - .padStart(7); - const noDsdMinAvg = data.stats['no-dsd-minimal'].avgRenderTime - .toFixed(1) - .padStart(10); const allSuccess = Object.values(data.stats).every( (s) => s.successRate >= 0.95 @@ -419,7 +379,7 @@ export async function runAllBenchmarks( const status = allSuccess ? 'āœ…' : 'āŒ'; console.log( - `${status} ${scenario.padEnd(13)} │ ${dsdAvg}ms │ ${noDsdAvg}ms │ ${dsdMinAvg}ms │ ${noDsdMinAvg}ms │` + `${status} ${scenario.padEnd(13)} │ ${dsdAvg}ms │ ${noDsdAvg}ms │` ); }); From f7c0749cc56d06a4e9f5cca44a9bfa23efa8881e Mon Sep 17 00:00:00 2001 From: Jim Simon Date: Tue, 23 Sep 2025 18:12:15 -0400 Subject: [PATCH 4/6] Update lint and formatter ignore files --- .eslintignore | 1 + .prettierignore | 1 + 2 files changed, 2 insertions(+) diff --git a/.eslintignore b/.eslintignore index f96e301fdd..2eda34dedb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -341,6 +341,7 @@ packages/labs/signals/node_modules/ packages/labs/signals/test/ packages/labs/signals/index.* +packages/labs/ssr/benchmarks/ packages/labs/ssr/demo/ packages/labs/ssr/lib/ packages/labs/ssr/node_modules/ diff --git a/.prettierignore b/.prettierignore index 94f713d4ec..0d47421dca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -329,6 +329,7 @@ packages/labs/signals/node_modules/ packages/labs/signals/test/ packages/labs/signals/index.* +packages/labs/ssr/benchmarks/ packages/labs/ssr/demo/ packages/labs/ssr/lib/ packages/labs/ssr/node_modules/ From b04269eb428761f3250fb6f154f60aeea34508a2 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Sat, 6 Sep 2025 00:01:43 -0700 Subject: [PATCH 5/6] [labs/ssr] Convert a few generators to plain functions --- packages/labs/ssr/src/lib/element-renderer.ts | 16 ++++++++------ .../labs/ssr/src/lib/lit-element-renderer.ts | 6 +++--- packages/labs/ssr/src/lib/render-value.ts | 21 +++++++++++-------- packages/labs/ssr/src/lib/render.ts | 4 ++-- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/labs/ssr/src/lib/element-renderer.ts b/packages/labs/ssr/src/lib/element-renderer.ts index 67b51b662f..0779367570 100644 --- a/packages/labs/ssr/src/lib/element-renderer.ts +++ b/packages/labs/ssr/src/lib/element-renderer.ts @@ -185,7 +185,8 @@ export abstract class ElementRenderer { * The default implementation serializes all attributes on the element * instance. */ - *renderAttributes(): RenderResult { + renderAttributes(): RenderResult { + let result = ''; if (this.element !== undefined) { const {attributes} = this.element; for ( @@ -194,12 +195,13 @@ export abstract class ElementRenderer { i++ ) { if (value === '' || value === undefined || value === null) { - yield ` ${name}`; + result += ` ${name}`; } else { - yield ` ${name}="${escapeHtml(value)}"`; + result += ` ${name}="${escapeHtml(value)}"`; } } } + return [result]; } } @@ -215,13 +217,15 @@ export class FallbackRenderer extends ElementRenderer { this._attributes[name.toLowerCase()] = value; } - override *renderAttributes(): RenderResult { + override renderAttributes(): RenderResult { + let result = ''; for (const [name, value] of Object.entries(this._attributes)) { if (value === '' || value === undefined || value === null) { - yield ` ${name}`; + result += ` ${name}`; } else { - yield ` ${name}="${escapeHtml(value)}"`; + result += ` ${name}="${escapeHtml(value)}"`; } } + return [result]; } } diff --git a/packages/labs/ssr/src/lib/lit-element-renderer.ts b/packages/labs/ssr/src/lib/lit-element-renderer.ts index a4f817a534..d6762d27b5 100644 --- a/packages/labs/ssr/src/lib/lit-element-renderer.ts +++ b/packages/labs/ssr/src/lib/lit-element-renderer.ts @@ -137,13 +137,13 @@ export class LitElementRenderer extends ElementRenderer { yield* renderValue((this.element as any).render(), renderInfo); } - override *renderLight(renderInfo: RenderInfo): RenderResult { + override renderLight(renderInfo: RenderInfo): RenderResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (this.element as any)?.renderLight(); if (value) { - yield* renderValue(value, renderInfo); + return renderValue(value, renderInfo); } else { - yield ''; + return []; } } } diff --git a/packages/labs/ssr/src/lib/render-value.ts b/packages/labs/ssr/src/lib/render-value.ts index 2aba97f1c5..9fa5212d3e 100644 --- a/packages/labs/ssr/src/lib/render-value.ts +++ b/packages/labs/ssr/src/lib/render-value.ts @@ -879,12 +879,12 @@ And the inner template was: ? getLast(renderInfo.customElementInstanceStack) : undefined; if (part.type === PartType.PROPERTY) { - yield* renderPropertyPart(instance, op, committedValue); + yield renderPropertyPart(instance, op, committedValue); } else if (part.type === PartType.BOOLEAN_ATTRIBUTE) { // Boolean attribute binding - yield* renderBooleanAttributePart(instance, op, committedValue); + yield renderBooleanAttributePart(instance, op, committedValue); } else { - yield* renderAttributePart(instance, op, committedValue); + yield renderAttributePart(instance, op, committedValue); } } partIndex += statics.length - 1; @@ -1078,7 +1078,7 @@ function throwErrorForPartIndexMismatch( throw new Error(errorMsg); } -function* renderPropertyPart( +function renderPropertyPart( instance: ElementRenderer | undefined, op: AttributePartOp, value: unknown @@ -1090,11 +1090,12 @@ function* renderPropertyPart( instance.setProperty(op.name, value); } if (reflectedName !== undefined) { - yield `${reflectedName}="${escapeHtml(String(value))}"`; + return `${reflectedName}="${escapeHtml(String(value))}"`; } + return ''; } -function* renderBooleanAttributePart( +function renderBooleanAttributePart( instance: ElementRenderer | undefined, op: AttributePartOp, value: unknown @@ -1103,12 +1104,13 @@ function* renderBooleanAttributePart( if (instance !== undefined) { instance.setAttribute(op.name, ''); } else { - yield op.name; + return op.name; } } + return ''; } -function* renderAttributePart( +function renderAttributePart( instance: ElementRenderer | undefined, op: AttributePartOp, value: unknown @@ -1117,9 +1119,10 @@ function* renderAttributePart( if (instance !== undefined) { instance.setAttribute(op.name, String(value ?? '')); } else { - yield `${op.name}="${escapeHtml(String(value ?? ''))}"`; + return `${op.name}="${escapeHtml(String(value ?? ''))}"`; } } + return ''; } /** diff --git a/packages/labs/ssr/src/lib/render.ts b/packages/labs/ssr/src/lib/render.ts index 267ddad582..69a58aa383 100644 --- a/packages/labs/ssr/src/lib/render.ts +++ b/packages/labs/ssr/src/lib/render.ts @@ -26,7 +26,7 @@ export type {RenderResult} from './render-result.js'; * to any reentrant calls to `render`, e.g. from a `renderShadow` callback * on an ElementRenderer. */ -export function* render( +export function render( value: unknown, renderInfo?: Partial ): RenderResult { @@ -43,5 +43,5 @@ export function* render( if (isTemplateResult(value)) { hydratable = isHydratable(value); } - yield* renderValue(value, renderInfo as RenderInfo, hydratable); + return renderValue(value, renderInfo as RenderInfo, hydratable); } From 049b250d2ee1b2ef9ad0108d76e339b9201efdc8 Mon Sep 17 00:00:00 2001 From: Jim Simon Date: Wed, 24 Sep 2025 16:29:15 -0400 Subject: [PATCH 6/6] Use string for internal return types instead of RenderResult --- packages/labs/ssr/src/lib/element-renderer.ts | 13 ++--- .../labs/ssr/src/lib/lit-element-renderer.ts | 17 +++--- packages/labs/ssr/src/lib/render-value.ts | 55 +++++++++++-------- packages/labs/ssr/src/lib/render.ts | 5 +- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/packages/labs/ssr/src/lib/element-renderer.ts b/packages/labs/ssr/src/lib/element-renderer.ts index 0779367570..9338526ecd 100644 --- a/packages/labs/ssr/src/lib/element-renderer.ts +++ b/packages/labs/ssr/src/lib/element-renderer.ts @@ -8,7 +8,6 @@ import {escapeHtml} from './util/escape-html.js'; import type {RenderInfo} from './render-value.js'; -import type {RenderResult} from './render-result.js'; type Interface = { [P in keyof T]: T[P]; @@ -168,14 +167,14 @@ export abstract class ElementRenderer { * If `renderShadow()` returns undefined, no declarative shadow root is * emitted. */ - renderShadow(_renderInfo: RenderInfo): RenderResult | undefined { + renderShadow(_renderInfo: RenderInfo): string | undefined { return undefined; } /** * Render the element's light DOM children. */ - renderLight(_renderInfo: RenderInfo): RenderResult | undefined { + renderLight(_renderInfo: RenderInfo): string | undefined { return undefined; } @@ -185,7 +184,7 @@ export abstract class ElementRenderer { * The default implementation serializes all attributes on the element * instance. */ - renderAttributes(): RenderResult { + renderAttributes(): string { let result = ''; if (this.element !== undefined) { const {attributes} = this.element; @@ -201,7 +200,7 @@ export abstract class ElementRenderer { } } } - return [result]; + return result; } } @@ -217,7 +216,7 @@ export class FallbackRenderer extends ElementRenderer { this._attributes[name.toLowerCase()] = value; } - override renderAttributes(): RenderResult { + override renderAttributes(): string { let result = ''; for (const [name, value] of Object.entries(this._attributes)) { if (value === '' || value === undefined || value === null) { @@ -226,6 +225,6 @@ export class FallbackRenderer extends ElementRenderer { result += ` ${name}="${escapeHtml(value)}"`; } } - return [result]; + return result; } } diff --git a/packages/labs/ssr/src/lib/lit-element-renderer.ts b/packages/labs/ssr/src/lib/lit-element-renderer.ts index d6762d27b5..88a155ab6a 100644 --- a/packages/labs/ssr/src/lib/lit-element-renderer.ts +++ b/packages/labs/ssr/src/lib/lit-element-renderer.ts @@ -13,7 +13,6 @@ import { } from '@lit-labs/ssr-dom-shim'; import {renderValue} from './render-value.js'; import type {RenderInfo} from './render-value.js'; -import type {RenderResult} from './render-result.js'; export type Constructor = {new (): T}; @@ -121,29 +120,31 @@ export class LitElementRenderer extends ElementRenderer { attributeToProperty(this.element as LitElement, name, value); } - override *renderShadow(renderInfo: RenderInfo): RenderResult { + override renderShadow(renderInfo: RenderInfo): string { // Render styles. + let result = ''; const styles = (this.element.constructor as typeof LitElement) .elementStyles; if (styles !== undefined && styles.length > 0) { - yield ''; + result += ''; } // Render template // eslint-disable-next-line @typescript-eslint/no-explicit-any - yield* renderValue((this.element as any).render(), renderInfo); + result += renderValue((this.element as any).render(), renderInfo); + return result; } - override renderLight(renderInfo: RenderInfo): RenderResult { + override renderLight(renderInfo: RenderInfo): string | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (this.element as any)?.renderLight(); if (value) { return renderValue(value, renderInfo); } else { - return []; + return undefined; } } } diff --git a/packages/labs/ssr/src/lib/render-value.ts b/packages/labs/ssr/src/lib/render-value.ts index 9fa5212d3e..e2e5243720 100644 --- a/packages/labs/ssr/src/lib/render-value.ts +++ b/packages/labs/ssr/src/lib/render-value.ts @@ -61,7 +61,6 @@ import { import {isRenderLightDirective} from '@lit-labs/ssr-client/directives/render-light.js'; import {reflectedAttributeName} from './reflected-attributes.js'; -import type {RenderResult} from './render-result.js'; import {isHydratable} from './server-template.js'; import type {Part} from 'lit-html'; @@ -723,11 +722,12 @@ declare global { } } -export function* renderValue( +export function renderValue( value: unknown, renderInfo: RenderInfo, hydratable = true -): RenderResult { +): string { + let result = ''; if (renderInfo.customElementHostStack.length === 0) { // If the SSR root event target is not at the start of the event target // stack, we add it to the beginning of the array. @@ -754,7 +754,7 @@ export function* renderValue( if (instance !== undefined) { const renderLightResult = instance.renderLight(renderInfo); if (renderLightResult !== undefined) { - yield* renderLightResult; + result += renderLightResult; } } value = null; @@ -766,17 +766,17 @@ export function* renderValue( } if (value != null && isTemplateResult(value)) { if (hydratable) { - yield ``; } - yield* renderTemplateResult(value as TemplateResult, renderInfo); + result += renderTemplateResult(value as TemplateResult, renderInfo); if (hydratable) { - yield ``; + result += ``; } } else { if (hydratable) { - yield ``; + result += ``; } if ( value === undefined || @@ -788,21 +788,22 @@ export function* renderValue( } else if (!isPrimitive(value) && isIterable(value)) { // Check that value is not a primitive, since strings are iterable for (const item of value) { - yield* renderValue(item, renderInfo, hydratable); + result += renderValue(item, renderInfo, hydratable); } } else { - yield escapeHtml(String(value)); + result += escapeHtml(String(value)); } if (hydratable) { - yield ``; + result += ``; } } + return result; } -function* renderTemplateResult( +function renderTemplateResult( result: TemplateResult, renderInfo: RenderInfo -): RenderResult { +): string { // In order to render a TemplateResult we have to handle and stream out // different parts of the result separately: // - Literal sections of the template @@ -823,11 +824,12 @@ function* renderTemplateResult( /* The next value in result.values to render */ let partIndex = 0; + let rendered = ''; for (const op of ops) { switch (op.type) { case 'text': - yield op.value; + rendered += op.value; break; case 'child-part': { const value = result.values[partIndex++]; @@ -845,7 +847,7 @@ And the inner template was: ); } } - yield* renderValue(value, renderInfo, isValueHydratable); + rendered += renderValue(value, renderInfo, isValueHydratable); break; } case 'attribute-part': { @@ -879,12 +881,16 @@ And the inner template was: ? getLast(renderInfo.customElementInstanceStack) : undefined; if (part.type === PartType.PROPERTY) { - yield renderPropertyPart(instance, op, committedValue); + rendered += renderPropertyPart(instance, op, committedValue); } else if (part.type === PartType.BOOLEAN_ATTRIBUTE) { // Boolean attribute binding - yield renderBooleanAttributePart(instance, op, committedValue); + rendered += renderBooleanAttributePart( + instance, + op, + committedValue + ); } else { - yield renderAttributePart(instance, op, committedValue); + rendered += renderAttributePart(instance, op, committedValue); } } partIndex += statics.length - 1; @@ -946,7 +952,7 @@ And the inner template was: } // Render out any attributes on the instance (both static and those // that may have been dynamically set by the renderer) - yield* instance.renderAttributes(); + rendered += instance.renderAttributes(); // If deferHydration flag is true or if this element is nested in // another, add the `defer-hydration` attribute, so that it does not // enable before the host element hydrates @@ -954,7 +960,7 @@ And the inner template was: renderInfo.deferHydration || renderInfo.customElementHostStack.length > 0 ) { - yield ' defer-hydration'; + rendered += ' defer-hydration'; } break; } @@ -968,7 +974,7 @@ And the inner template was: renderInfo.customElementHostStack.length > 0 ) { if (hydratable) { - yield ``; + rendered += ``; } } break; @@ -992,9 +998,9 @@ And the inner template was: const delegatesfocusAttr = delegatesFocus ? ' shadowrootdelegatesfocus' : ''; - yield `'; + rendered += `'; } renderInfo.customElementHostStack.pop(); break; @@ -1057,6 +1063,7 @@ And the inner template was: if (partIndex !== result.values.length) { throwErrorForPartIndexMismatch(partIndex, result); } + return rendered; } function throwErrorForPartIndexMismatch( diff --git a/packages/labs/ssr/src/lib/render.ts b/packages/labs/ssr/src/lib/render.ts index 69a58aa383..1b6c06e881 100644 --- a/packages/labs/ssr/src/lib/render.ts +++ b/packages/labs/ssr/src/lib/render.ts @@ -26,7 +26,7 @@ export type {RenderResult} from './render-result.js'; * to any reentrant calls to `render`, e.g. from a `renderShadow` callback * on an ElementRenderer. */ -export function render( +export function* render( value: unknown, renderInfo?: Partial ): RenderResult { @@ -43,5 +43,6 @@ export function render( if (isTemplateResult(value)) { hydratable = isHydratable(value); } - return renderValue(value, renderInfo as RenderInfo, hydratable); + + yield renderValue(value, renderInfo as RenderInfo, hydratable); }