diff --git a/modules/benchmarks/src/hydration/BUILD.bazel b/modules/benchmarks/src/hydration/BUILD.bazel new file mode 100644 index 00000000000000..7acfeade707890 --- /dev/null +++ b/modules/benchmarks/src/hydration/BUILD.bazel @@ -0,0 +1,41 @@ +load("//tools:defaults.bzl", "ng_module", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "shared_lib", + srcs = [ + "init.ts", + "table.ts", + "util.ts", + ], + tsconfig = "//modules/benchmarks:tsconfig-build.json", + deps = [ + "//modules/benchmarks/src:util_lib", + "//packages/common", + "//packages/core", + "//packages/platform-browser", + ], +) + +ts_library( + name = "perf_tests_lib", + testonly = 1, + srcs = ["hydration.perf-spec.ts"], + tsconfig = "//modules/benchmarks:tsconfig-e2e.json", + deps = [ + "@npm//@angular/build-tooling/bazel/benchmark/driver-utilities", + "@npm//protractor", + ], +) + +ts_library( + name = "e2e_tests_lib", + testonly = 1, + srcs = ["hydration.e2e-spec.ts"], + tsconfig = "//modules/benchmarks:tsconfig-e2e.json", + deps = [ + "@npm//@angular/build-tooling/bazel/benchmark/driver-utilities", + "@npm//protractor", + ], +) diff --git a/modules/benchmarks/src/hydration/README.md b/modules/benchmarks/src/hydration/README.md new file mode 100644 index 00000000000000..220ee9edd0eea9 --- /dev/null +++ b/modules/benchmarks/src/hydration/README.md @@ -0,0 +1,10 @@ +# Hydration benchmark + +This folder contains hydration benchmark that tests the process of matching DOM nodes at runtime. + +There are 2 folders in this benchmark: + +* `baseline` - renders a component without hydration, we use it as a baseline +* `main` - the same code as the `baseline`, but Angular uses hydration and matches existing DOM nodes instead of creating new ones + +The benchmarks are based on `largetable` benchmarks. diff --git a/modules/benchmarks/src/hydration/baseline/BUILD.bazel b/modules/benchmarks/src/hydration/baseline/BUILD.bazel new file mode 100644 index 00000000000000..5aad13a0b81f08 --- /dev/null +++ b/modules/benchmarks/src/hydration/baseline/BUILD.bazel @@ -0,0 +1,55 @@ +load("//tools:defaults.bzl", "app_bundle", "http_server", "ng_module") +load("@npm//@angular/build-tooling/bazel/benchmark/component_benchmark:benchmark_test.bzl", "benchmark_test") +load("//modules/benchmarks:e2e_test.bzl", "e2e_test") + +package(default_visibility = ["//modules/benchmarks:__subpackages__"]) + +ng_module( + name = "main", + srcs = glob(["*.ts"]), + tsconfig = "//modules/benchmarks:tsconfig-build.json", + deps = [ + "//modules/benchmarks/src:util_lib", + "//modules/benchmarks/src/hydration:shared_lib", + "//packages/core", + "//packages/platform-browser", + ], +) + +app_bundle( + name = "bundle", + entry_point = ":index.ts", + deps = [ + ":main", + "@npm//rxjs", + ], +) + +# The script needs to be called `app_bundle` for easier syncing into g3. +genrule( + name = "app_bundle", + srcs = [":bundle.debug.min.js"], + outs = ["app_bundle.js"], + cmd = "cp $< $@", +) + +http_server( + name = "prodserver", + srcs = ["index.html"], + deps = [ + ":app_bundle", + "//packages/zone.js/bundles:zone.umd.js", + ], +) + +benchmark_test( + name = "perf", + server = ":prodserver", + deps = ["//modules/benchmarks/src/hydration:perf_tests_lib"], +) + +e2e_test( + name = "e2e", + server = ":prodserver", + deps = ["//modules/benchmarks/src/hydration:e2e_tests_lib"], +) diff --git a/modules/benchmarks/src/hydration/baseline/index.html b/modules/benchmarks/src/hydration/baseline/index.html new file mode 100644 index 00000000000000..a571ee82071f72 --- /dev/null +++ b/modules/benchmarks/src/hydration/baseline/index.html @@ -0,0 +1,43 @@ + + + + + + + + + +

Params

+
+ Cols: + +
+ Rows: + +
+ +
+ +

Hydration Benchmark (baseline)

+

+ + + + + +

+ +
+ +
+ + + + + +
+ + + + + diff --git a/modules/benchmarks/src/hydration/baseline/index.ts b/modules/benchmarks/src/hydration/baseline/index.ts new file mode 100644 index 00000000000000..5b327b7258af70 --- /dev/null +++ b/modules/benchmarks/src/hydration/baseline/index.ts @@ -0,0 +1,20 @@ +/** + * @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.io/license + */ + +import {bootstrapApplication, provideProtractorTestingSupport} from '@angular/platform-browser'; + +import {init, syncUrlParamsToForm} from '../init'; +import {AppComponent} from '../table'; + +syncUrlParamsToForm(); + +bootstrapApplication(AppComponent, { + providers: [ + provideProtractorTestingSupport(), + ], +}).then(appRef => init(appRef, false /* insertSsrContent */)); diff --git a/modules/benchmarks/src/hydration/hydration.e2e-spec.ts b/modules/benchmarks/src/hydration/hydration.e2e-spec.ts new file mode 100644 index 00000000000000..1b2de63fa02005 --- /dev/null +++ b/modules/benchmarks/src/hydration/hydration.e2e-spec.ts @@ -0,0 +1,28 @@ +/** + * @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.io/license + */ + +import {openBrowser, verifyNoBrowserErrors} from '@angular/build-tooling/bazel/benchmark/driver-utilities'; +import {$} from 'protractor'; + +describe('hydration benchmark', () => { + afterEach(verifyNoBrowserErrors); + + it(`should render the table`, async () => { + openBrowser({ + url: '', + ignoreBrowserSynchronization: true, + params: [{name: 'cols', value: 5}, {name: 'rows', value: 5}], + }); + await $('#createDom').click(); + expect($('#table').getText()).toContain('0/0'); + await $('#createDom').click(); + expect($('#table').getText()).toContain('A/A'); + await $('#destroyDom').click(); + expect($('#table').getText() as any).toEqual(''); + }); +}); diff --git a/modules/benchmarks/src/hydration/hydration.perf-spec.ts b/modules/benchmarks/src/hydration/hydration.perf-spec.ts new file mode 100644 index 00000000000000..b9860ecf2f0272 --- /dev/null +++ b/modules/benchmarks/src/hydration/hydration.perf-spec.ts @@ -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.io/license + */ + +import {runBenchmark, verifyNoBrowserErrors} from '@angular/build-tooling/bazel/benchmark/driver-utilities'; +import {$} from 'protractor'; + +interface Worker { + id: string; + prepare?(): void; + work(): void; +} + +const CreateWorker: Worker = { + id: 'create', + prepare: () => $('#prepare').click(), + work: () => $('#createDom').click() +}; + +const UpdateWorker: Worker = { + id: 'update', + prepare: () => { + $('#prepare').click(); + $('#createDom').click(); + }, + work: () => $('#updateDom').click() +}; + +// In order to make sure that we don't change the ids of the benchmarks, we need to +// determine the current test package name from the Bazel target. This is necessary +// because previous to the Bazel conversion, the benchmark test ids contained the test +// name. e.g. "largeTable.ng2_switch.createDestroy". We determine the name of the +// Bazel package where this test runs from the current test target. The Bazel target +// looks like: "//modules/benchmarks/src/largetable/{pkg_name}:{target_name}". +const testPackageName = process.env['BAZEL_TARGET']!.split(':')[0].split('/').pop(); + +describe('hydration benchmark perf', () => { + afterEach(verifyNoBrowserErrors); + + [CreateWorker, UpdateWorker].forEach((worker) => { + describe(worker.id, () => { + it(`should run benchmark for ${testPackageName}`, async () => { + await runTableBenchmark({ + id: `hydration.${testPackageName}.${worker.id}`, + url: '/', + ignoreBrowserSynchronization: true, + worker, + }); + }); + }); + }); +}); + +function runTableBenchmark( + config: {id: string, url: string, ignoreBrowserSynchronization?: boolean, worker: Worker}) { + return runBenchmark({ + id: config.id, + url: config.url, + ignoreBrowserSynchronization: config.ignoreBrowserSynchronization, + params: [{name: 'cols', value: 40}, {name: 'rows', value: 200}], + prepare: config.worker.prepare, + work: config.worker.work + }); +} diff --git a/modules/benchmarks/src/hydration/init.ts b/modules/benchmarks/src/hydration/init.ts new file mode 100644 index 00000000000000..3cada325c01da4 --- /dev/null +++ b/modules/benchmarks/src/hydration/init.ts @@ -0,0 +1,123 @@ +/** + * @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.io/license + */ + +import {ApplicationRef, ComponentRef, createComponent, EnvironmentInjector} from '@angular/core'; + +import {bindAction, profile} from '../util'; + +import {TableComponent} from './table'; +import {buildTable, emptyTable, initTableUtils, TableCell} from './util'; + +const DEFAULT_COLS_COUNT = '40'; +const DEFAULT_ROWS_COUNT = '200'; + +function getUrlParamValue(name: string): string|null { + const url = new URL(document.location.href); + return url.searchParams.get(name); +} + +export function syncUrlParamsToForm(): {cols: string, rows: string} { + let cols = getUrlParamValue('cols') ?? DEFAULT_COLS_COUNT; + let rows = getUrlParamValue('rows') ?? DEFAULT_ROWS_COUNT; + (document.getElementById('cols') as HTMLInputElement).value = cols; + (document.getElementById('rows') as HTMLInputElement).value = rows; + return {cols, rows}; +} + +export function init(appRef: ApplicationRef, insertSsrContent = true) { + let tableComponentRef: ComponentRef; + const injector = appRef.injector; + const environmentInjector = injector.get(EnvironmentInjector); + + let data: TableCell[][] = []; + + const setInput = (data: TableCell[][]) => { + if (tableComponentRef) { + tableComponentRef.setInput('data', data); + tableComponentRef.changeDetectorRef.detectChanges(); + } + }; + + function destroyDom() { + setInput(emptyTable); + } + + function updateDom() { + data = buildTable(); + setInput(data); + } + + function createDom() { + const hostElement = document.getElementById('table'); + tableComponentRef = createComponent(TableComponent, {environmentInjector, hostElement}); + setInput(data); + } + + function prepare() { + destroyDom(); + data = buildTable(); + + if (insertSsrContent) { + // Prepare DOM structure, similar to what SSR would produce. + const hostElement = document.getElementById('table'); + hostElement.setAttribute('ngh', '0'); + hostElement.textContent = ''; // clear existing DOM contents + hostElement.appendChild(createTableDom(data)); + } + } + + function noop() {} + + initTableUtils(); + + bindAction('#prepare', prepare); + bindAction('#createDom', createDom); + bindAction('#updateDom', updateDom); + bindAction('#createDomProfile', profile(createDom, prepare, 'create')); + bindAction('#updateDomProfile', profile(updateDom, noop, 'update')); +} + +/** + * Creates DOM to represent a table, similar to what'd be generated + * during the SSR. + */ +function createTableDom(data: TableCell[][]) { + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + table.appendChild(tbody); + this._renderCells = []; + for (let r = 0; r < data.length; r++) { + const dataRow = data[r]; + const tr = document.createElement('tr'); + // Mark created DOM nodes, so that we can verify that + // they were *not* re-created during hydration. + (tr as any).__existing = true; + tbody.appendChild(tr); + const renderRow = []; + for (let c = 0; c < dataRow.length; c++) { + const dataCell = dataRow[c]; + const renderCell = document.createElement('td'); + // Mark created DOM nodes, so that we can verify that + // they were *not* re-created during hydration. + (renderCell as any).__existing = true; + if (r % 2 === 0) { + renderCell.style.backgroundColor = 'grey'; + } + tr.appendChild(renderCell); + renderRow[c] = renderCell; + renderCell.textContent = dataCell.value; + } + // View container anchor + const comment = document.createComment(''); + tr.appendChild(comment); + } + // View container anchor + const comment = document.createComment(''); + tbody.appendChild(comment); + return table; +} diff --git a/modules/benchmarks/src/hydration/main/BUILD.bazel b/modules/benchmarks/src/hydration/main/BUILD.bazel new file mode 100644 index 00000000000000..5aad13a0b81f08 --- /dev/null +++ b/modules/benchmarks/src/hydration/main/BUILD.bazel @@ -0,0 +1,55 @@ +load("//tools:defaults.bzl", "app_bundle", "http_server", "ng_module") +load("@npm//@angular/build-tooling/bazel/benchmark/component_benchmark:benchmark_test.bzl", "benchmark_test") +load("//modules/benchmarks:e2e_test.bzl", "e2e_test") + +package(default_visibility = ["//modules/benchmarks:__subpackages__"]) + +ng_module( + name = "main", + srcs = glob(["*.ts"]), + tsconfig = "//modules/benchmarks:tsconfig-build.json", + deps = [ + "//modules/benchmarks/src:util_lib", + "//modules/benchmarks/src/hydration:shared_lib", + "//packages/core", + "//packages/platform-browser", + ], +) + +app_bundle( + name = "bundle", + entry_point = ":index.ts", + deps = [ + ":main", + "@npm//rxjs", + ], +) + +# The script needs to be called `app_bundle` for easier syncing into g3. +genrule( + name = "app_bundle", + srcs = [":bundle.debug.min.js"], + outs = ["app_bundle.js"], + cmd = "cp $< $@", +) + +http_server( + name = "prodserver", + srcs = ["index.html"], + deps = [ + ":app_bundle", + "//packages/zone.js/bundles:zone.umd.js", + ], +) + +benchmark_test( + name = "perf", + server = ":prodserver", + deps = ["//modules/benchmarks/src/hydration:perf_tests_lib"], +) + +e2e_test( + name = "e2e", + server = ":prodserver", + deps = ["//modules/benchmarks/src/hydration:e2e_tests_lib"], +) diff --git a/modules/benchmarks/src/hydration/main/index.html b/modules/benchmarks/src/hydration/main/index.html new file mode 100644 index 00000000000000..f0a23b3db4b844 --- /dev/null +++ b/modules/benchmarks/src/hydration/main/index.html @@ -0,0 +1,43 @@ + + + + + + + + + +

Params

+
+ Cols: + +
+ Rows: + +
+ +
+ +

Hydration Benchmark (main)

+

+ + + + + +

+ +
+ +
+ + + + + +
+ + + + + diff --git a/modules/benchmarks/src/hydration/main/index.ts b/modules/benchmarks/src/hydration/main/index.ts new file mode 100644 index 00000000000000..a74454c14f281d --- /dev/null +++ b/modules/benchmarks/src/hydration/main/index.ts @@ -0,0 +1,22 @@ +/** + * @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.io/license + */ + +import {bootstrapApplication, provideClientHydration, provideProtractorTestingSupport} from '@angular/platform-browser'; + +import {init, syncUrlParamsToForm} from '../init'; +import {AppComponent, setupTransferState} from '../table'; + +const params = syncUrlParamsToForm(); +setupTransferState(params.cols, params.rows); + +bootstrapApplication(AppComponent, { + providers: [ + provideClientHydration(), + provideProtractorTestingSupport(), + ], +}).then(appRef => init(appRef, true /* insertSsrContent */)); diff --git a/modules/benchmarks/src/hydration/table.ts b/modules/benchmarks/src/hydration/table.ts new file mode 100644 index 00000000000000..512bcef399c040 --- /dev/null +++ b/modules/benchmarks/src/hydration/table.ts @@ -0,0 +1,69 @@ +/** + * @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.io/license + */ + +import {CommonModule} from '@angular/common'; +import {Component, Input} from '@angular/core'; +import {DomSanitizer, SafeStyle} from '@angular/platform-browser'; + +import {TableCell} from './util'; + +let trustedEmptyColor: SafeStyle; +let trustedGreyColor: SafeStyle; + +@Component({ + standalone: true, + selector: 'app', + template: ``, +}) +export class AppComponent { + constructor(sanitizer: DomSanitizer) { + trustedEmptyColor = sanitizer.bypassSecurityTrustStyle('white'); + trustedGreyColor = sanitizer.bypassSecurityTrustStyle('grey'); + } +} + +@Component({ + standalone: true, + selector: 'table-cmp', + imports: [CommonModule], + template: ` + + + + + + +
+ {{cell.value + '!'}} +
+ `, +}) +export class TableComponent { + @Input() data: TableCell[][] = []; + + trackByIndex(index: number, item: any) { + return index; + } + + getColor(row: number) { + return row % 2 ? trustedEmptyColor : trustedGreyColor; + } +} + +export function setupTransferState(cols: string, rows: string) { + // TODO create a script with correct data! + // + const script = document.createElement('script'); + script.id = 'ng-state'; + script.type = 'application/json'; + script.textContent = + `{"__ɵnghData__":[{"t":{"2":"t0"},"c":{"2":[{"i":"t0","r":1,"t":{"1":"t1"},"c":{"1":[{"i":"t1","r":1,"x":${ + cols}}]},"x":${rows}}]}}]}`; + document.body.insertBefore(script, document.body.firstChild); +} diff --git a/modules/benchmarks/src/hydration/util.ts b/modules/benchmarks/src/hydration/util.ts new file mode 100644 index 00000000000000..a99c9df64a82c3 --- /dev/null +++ b/modules/benchmarks/src/hydration/util.ts @@ -0,0 +1,48 @@ +/** + * @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.io/license + */ + +import {getIntParameter} from '../util'; + +export class TableCell { + constructor(public row: number, public col: number, public value: string) {} +} + +let tableCreateCount: number; +let maxRow: number; +let maxCol: number; +let numberData: TableCell[][]; +let charData: TableCell[][]; + +export function initTableUtils() { + maxRow = getIntParameter('rows'); + maxCol = getIntParameter('cols'); + tableCreateCount = 0; + numberData = []; + charData = []; + for (let r = 0; r < maxRow; r++) { + const numberRow: TableCell[] = []; + numberData.push(numberRow); + const charRow: TableCell[] = []; + charData.push(charRow); + for (let c = 0; c < maxCol; c++) { + numberRow.push(new TableCell(r, c, `${c}/${r}`)); + charRow.push(new TableCell(r, c, `${charValue(c)}/${charValue(r)}`)); + } + } +} + +function charValue(i: number): string { + return String.fromCharCode('A'.charCodeAt(0) + (i % 26)); +} + +export const emptyTable: TableCell[][] = []; + +export function buildTable(): TableCell[][] { + tableCreateCount++; + return tableCreateCount % 2 ? numberData : charData; +}